[
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    open-pull-requests-limit: 5\n    labels:\n      - \"dependencies\"\n    groups:\n      golang-x:\n        patterns: [\"golang.org/x/*\"]\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    open-pull-requests-limit: 5\n    labels:\n      - \"dependencies\"\n"
  },
  {
    "path": ".github/workflows/docker-hub.yml",
    "content": "name: publish to docker hub\n\non:\n  release:\n    types: [published]\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v6\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: jakebark/tag-nag\n\n      - name: Build and push Docker image\n        id: push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: ./Dockerfile\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            TERRAFORM_VERSION=${{ vars.TERRAFORM_VERSION }}\n\n"
  },
  {
    "path": ".github/workflows/go_tests.yml",
    "content": "name: go test\n\non:\n  pull_request:\n    branches: [ main ] \n\njobs:\n  test: \n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest]\n        go-version: ['1.21', '1.22', '1.23', '1.24']\n\n    steps:\n      - name: checkout code\n        uses: actions/checkout@v6 \n\n      - name: setup go\n        uses: actions/setup-go@v5 \n        with:\n          go-version: ${{ matrix.go-version }} \n          cache: true \n\n      - name: run go tests\n        run: go test -v ./... \n"
  },
  {
    "path": ".github/workflows/semgrep.yml",
    "content": "name: semgrep\n\non:\n  pull_request:\n    branches: [ main ] \n\njobs:\n  semgrep:\n    runs-on: ubuntu-latest\n    \n    container:\n      image: semgrep/semgrep\n\n    steps:\n      - name: checkout code\n        uses: actions/checkout@v6\n\n      - name: run semgrep\n        run: semgrep ci\n"
  },
  {
    "path": ".gitignore",
    "content": "*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n*.test\n**/test/*\ntodo.md\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n\n.DS_Store\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.22 AS builder\n\nWORKDIR /app\n\nCOPY go.mod go.sum ./\nRUN go mod download \nRUN go mod tidy\n\nCOPY . .\n\nRUN go build -o tag-nag\n\nFROM debian:stable-slim\n\nARG TERRAFORM_VERSION\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends \\\n    wget \\\n    unzip \\\n    ca-certificates \\\n    git && \\\n    rm -rf /var/lib/apt/lists/*\n\nRUN wget \"https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip\" && \\\n    unzip \"terraform_${TERRAFORM_VERSION}_linux_amd64.zip\" -d /usr/local/bin && \\\n    rm \"terraform_${TERRAFORM_VERSION}_linux_amd64.zip\" && \\\n    chmod +x /usr/local/bin/terraform\n\nRUN groupadd -r appgroup && \\\n    useradd --no-log-init -r -g appgroup appuser\n\nCOPY --from=builder /app/tag-nag /usr/local/bin/tag-nag\n\nUSER appuser\n\nWORKDIR /workspace\n\nENTRYPOINT [\"tag-nag\"]\n\nCMD [\"--help\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 jakebark\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# tag-nag\n\n<img src=\"./img/demo.gif\" width=\"650\">\n\nValidate AWS tags in Terraform and CloudFormation.  \n\n## Installation\n```bash\ngo install github.com/jakebark/tag-nag@latest\n```\nYou may need to set [GOPATH](https://go.dev/wiki/SettingGOPATH).\n\n## Commands\n\nTag-nag will search a file or directory for tag keys. Directory search is recursive.\n\n```bash\ntag-nag <file/directory> --tags \"Key1,Key2\"\n\ntag-nag main.tf --tags \"Owner\" # run against a file\ntag-nag ./my_project --tags \"Owner,Environment\" # run against a directory\ntag-nag . --tags \"Owner\", \"Environment\" # will take string or list\n\n```\n\nSearch for tag keys *and* tag values\n\n```bash\ntag-nag <file/directory> --tags \"Key[Value]\"\n\ntag-nag main.tf --tags \"Owner[Jake]\" \ntag-nag main.tf --tags \"Owner[Jake],Environment\" # mixed search possible\ntag-nag main.tf --tags \"Owner[Jake],Environment[Dev,Prod]\" # multiple options for tag values\n\n```\n\nFlags\n```bash\n-c --case-insensitive  \n-d --dry-run # will always exit successfully\n--cfn-spec ~/path/to/CloudFormationResourceSpecification.json # path to Cfn spec file, filters taggable resources\n-s --skip \"file.tf, path/to/directory\" # skip files and directories\n-o --output json # output to json (default is text)\n```\n\n## Config file\n\nThe above commands can be issued with a `.tag-nag.yml` file in the same directory where tag-nag is run. \n\nSee the [example .tag-nag.yml file](./examples/.tag-nag.yml).  \n\n## Skip Checks\n\nSkip file\n```hcl\n#tag-nag ignore-all\n```\n\nTerraform\n```hcl\nresource \"aws_s3_bucket\" \"this\" {\n  #tag-nag ignore\n  bucket   = \"that\"\n}\n```\n\nCloudFormation\n```yaml\nEC2Instance:  #tag-nag ignore\n    Type: \"AWS::EC2::Instance\"\n    Properties: \n      ImageId: ami-12a34b\n      InstanceType: c1.xlarge   \n```\n\n## Filtering taggable resources\n\nSome AWS resources cannot be tagged. \n\nTo filter out these resources with Terraform, run tag-nag against an initialised directory (`terraform init`).\n\nTo filter out these resources with CloudFormation, specify a path to the [CloudFormation JSON spec file](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html) with the `--cfn-spec` input. \n\n## Docker\nRun\n```bash\ndocker pull jakebark/tag-nag:latest\ndocker run --rm -v $(pwd):/workspace -w /workspace jakebark/tag-nag \\\n  . --tags \"Owner,Environment\" \n\n```\n\nInteractive shell\n```bash\ndocker pull jakebark/tag-nag:latest\ndocker run -it --rm \\\n  -v \"$(pwd)\":/workspace \\\n  -w /workspace \\\n  --entrypoint /bin/sh jakebark/tag-nag:latest\n```\n\nThe image contains terraform, allowing `terraform init` to be run, if required.  \n```bash\ndocker pull jakebark/tag-nag:latest\ndocker run --rm -v $(pwd):/workspace -w /workspace \\\n  --entrypoint /bin/sh jakebark/tag-nag:latest \\\n  -c \"terraform init -input=false -no-color && tag-nag\\\n     . --tags 'Owner,Environment'\"\n```\n\n## CI/CD\n\nExample CI files:\n- [GitHub](./examples/github.yml)\n- [GitLab](./examples/gitlab.yml)\n- [AWS CodeBuild](./examples/codebuild.yml)\n\n## Related Resources\n\n- [pkg.go.dev/github.com/jakebark/tag-nag](https://pkg.go.dev/github.com/jakebark/tag-nag)\n\n<div align=\"center\">\n<img alt=\"tag:nag\" height=\"150\" src=\"./img/tag.png\" />\n</div>\n"
  },
  {
    "path": "examples/.tag-nag.yml",
    "content": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project\n\nsettings:\n  case_insensitive: false\n  dry_run: false\n  cfn_spec: \"~/path/to/CloudFormationResourceSpecification.json\" # remove if not using \n\nskip:\n  - file.tf\n  - \"test-data/**\"    # keep quotes for wildcard/glob pattern\n  - \"*.tmp\"\n  - .terraform\n  - .git\n\n\n"
  },
  {
    "path": "examples/codebuild.yml",
    "content": "## codebuild image 'jakebar/tag-nag:$latest'\nversion: 0.2\n\nphases:\n  build:\n    commands:\n      - cd \"$CODEBUILD_SRC_DIR\"\n      - terraform init -backend=false # remove for CloudFormation\n      - tag-nag . --tags \"tags\"\n"
  },
  {
    "path": "examples/github.yml",
    "content": "name: tag-nag\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  tag-nag:\n    runs-on: ubuntu-latest\n    \n    container:\n      image: jakebark/tag-nag:latest\n\n    steps:\n      - name: checkout code\n        uses: actions/checkout@v4\n\n      - name: run terraform init # remove for CloudFormation\n        run: terraform init -backend=false\n         \n      - name: run tag-nag\n        run: tag-nag . --tags \"tags\"\n"
  },
  {
    "path": "examples/gitlab.yml",
    "content": "stages:\n  - validate\n\ntag-nag:\n  stage: validate\n  image: jakebark/tag-nag:latest\n  script:\n    - terraform init -backend=false # remove for CloudFormation\n    - tag-nag . --tags \"tags\"\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/jakebark/tag-nag\n\ngo 1.22\n\nrequire (\n\tgithub.com/hashicorp/hcl/v2 v2.23.0\n\tgithub.com/spf13/pflag v1.0.6\n\tgithub.com/zclconf/go-cty v1.13.0\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tgithub.com/agext/levenshtein v1.2.1 // indirect\n\tgithub.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect\n\tgithub.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect\n\tgithub.com/google/go-cmp v0.6.0 // indirect\n\tgithub.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect\n\tgolang.org/x/mod v0.8.0 // indirect\n\tgolang.org/x/sys v0.5.0 // indirect\n\tgolang.org/x/text v0.11.0 // indirect\n\tgolang.org/x/tools v0.6.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=\ngithub.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=\ngithub.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=\ngithub.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=\ngithub.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=\ngithub.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=\ngithub.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=\ngithub.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=\ngithub.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=\ngithub.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=\ngithub.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=\ngithub.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=\ngithub.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=\ngithub.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=\ngithub.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=\ngolang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=\ngolang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/cloudformation/helpers.go",
    "content": "package cloudformation\n\nimport (\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// mapNodes converts a yaml mapping node into a go map\nfunc mapNodes(node *yaml.Node) map[string]*yaml.Node {\n\tm := make(map[string]*yaml.Node)\n\tif node == nil || node.Kind != yaml.MappingNode {\n\t\treturn m\n\t}\n\tfor i := 0; i < len(node.Content); i += 2 {\n\t\tkeyNode := node.Content[i]\n\t\tvalueNode := node.Content[i+1]\n\t\tm[keyNode.Value] = valueNode\n\t}\n\treturn m\n}\n\n// findMapNode parses a yaml block and returns the value, when given the key\nfunc findMapNode(node *yaml.Node, key string) *yaml.Node {\n\tif node.Kind == yaml.DocumentNode && len(node.Content) > 0 {\n\t\tnode = node.Content[0]\n\t}\n\tif node.Kind != yaml.MappingNode {\n\t\treturn nil\n\t}\n\tfor i := 0; i < len(node.Content); i += 2 {\n\t\tk := node.Content[i]\n\t\tv := node.Content[i+1]\n\t\tif k.Value == key {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn nil\n}\n\n// parseYAML unmarshal yaml and return a pointer to the root of the node\nfunc parseYAML(filePath string) (*yaml.Node, error) {\n\tdata, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar root yaml.Node\n\tif err := yaml.Unmarshal(data, &root); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif root.Kind == yaml.DocumentNode && len(root.Content) > 0 {\n\t\troot = *root.Content[0]\n\t}\n\treturn &root, nil\n}\n\nfunc skipResource(node *yaml.Node, lines []string) bool {\n\tindex := node.Line - 2\n\tif index < len(lines) {\n\t\tif strings.Contains(lines[index], config.TagNagIgnore) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/cloudformation/helpers_test.go",
    "content": "package cloudformation\n\nimport (\n\t\"testing\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// helper to create a yaml.Node for testing\nfunc createYamlNode(t *testing.T, yamlStr string) *yaml.Node {\n\tt.Helper()\n\tvar node yaml.Node\n\terr := yaml.Unmarshal([]byte(yamlStr), &node)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to unmarshal test YAML: %v\\nYAML:\\n%s\", err, yamlStr)\n\t}\n\tif node.Kind == yaml.DocumentNode && len(node.Content) > 0 {\n\t\treturn node.Content[0]\n\t}\n\treturn &node\n}\n\nfunc testMapNodes(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tinputYAML    string\n\t\texpectedKeys []string\n\t}{\n\t\t{\n\t\t\tname:         \"simple map\",\n\t\t\tinputYAML:    `Key1: Value1\\nKey2: 123`,\n\t\t\texpectedKeys: []string{\"Key1\", \"Key2\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"nested map\",\n\t\t\tinputYAML:    `Key1: Value1\\nKey2:\\n  Nested1: NestedValue`,\n\t\t\texpectedKeys: []string{\"Key1\", \"Key2\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"empty map\",\n\t\t\tinputYAML:    `{}`,\n\t\t\texpectedKeys: []string{},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tnode := createYamlNode(t, tc.inputYAML)\n\t\t\tmapped := mapNodes(node)\n\t\t\tgotKeys := make([]string, 0, len(mapped))\n\t\t\tfor k := range mapped {\n\t\t\t\tgotKeys = append(gotKeys, k)\n\t\t\t}\n\t\t\tif len(gotKeys) != len(tc.expectedKeys) {\n\t\t\t\tt.Errorf(\"mapNodes() returned map with %d keys, expected %d. Got keys: %v\", len(gotKeys), len(tc.expectedKeys), gotKeys)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testFindMapNode(t *testing.T) {\n\tyamlContent := `\n        RootKey: RootValue\n        Map1:\n          NestedKey1: NestedValue1\n          NestedKey2: 123\n        Map2: {}\n        List1:\n          - itemA\n          - itemB\n`\n\trootNode := createYamlNode(t, yamlContent)\n\n\ttests := []struct {\n\t\tname          string\n\t\tnode          *yaml.Node\n\t\tkey           string\n\t\texpectedNil   bool\n\t\texpectedKind  yaml.Kind\n\t\texpectedValue string\n\t}{\n\t\t{\n\t\t\tname:          \"root key to root value\",\n\t\t\tnode:          rootNode,\n\t\t\tkey:           \"RootKey\",\n\t\t\texpectedNil:   false,\n\t\t\texpectedKind:  yaml.ScalarNode,\n\t\t\texpectedValue: \"RootValue\",\n\t\t},\n\t\t{\n\t\t\tname:         \"map key to map values\",\n\t\t\tnode:         rootNode,\n\t\t\tkey:          \"Map1\",\n\t\t\texpectedNil:  false,\n\t\t\texpectedKind: yaml.MappingNode,\n\t\t},\n\t\t{\n\t\t\tname:         \"map key to empty map value\",\n\t\t\tnode:         rootNode,\n\t\t\tkey:          \"Map2\",\n\t\t\texpectedNil:  false,\n\t\t\texpectedKind: yaml.MappingNode,\n\t\t},\n\t\t{\n\t\t\tname:         \"list key to list values\",\n\t\t\tnode:         rootNode,\n\t\t\tkey:          \"List1\",\n\t\t\texpectedNil:  false,\n\t\t\texpectedKind: yaml.SequenceNode,\n\t\t},\n\t\t{\n\t\t\tname:        \"missing key\",\n\t\t\tnode:        rootNode,\n\t\t\tkey:         \"MissingKey\",\n\t\t\texpectedNil: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"search other node\",\n\t\t\tnode:        findMapNode(rootNode, \"List1\"),\n\t\t\tkey:         \"itemA\",\n\t\t\texpectedNil: true,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := findMapNode(tc.node, tc.key)\n\t\t\tisNil := got == nil\n\t\t\tif isNil != tc.expectedNil {\n\t\t\t\tt.Fatalf(\"findMapNode(key=%q) returned nil? %t, expectedNil %t\", tc.key, isNil, tc.expectedNil)\n\t\t\t}\n\t\t\tif !tc.expectedNil {\n\t\t\t\tif got.Kind != tc.expectedKind {\n\t\t\t\t\tt.Errorf(\"findMapNode(key=%q) returned node kind %v, expected %v\", tc.key, got.Kind, tc.expectedKind)\n\t\t\t\t}\n\t\t\t\tif tc.expectedKind == yaml.ScalarNode && got.Value != tc.expectedValue {\n\t\t\t\t\tt.Errorf(\"findMapNode(key=%q) returned scalar value %q, expected %q\", tc.key, got.Value, tc.expectedValue)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n"
  },
  {
    "path": "internal/cloudformation/process.go",
    "content": "package cloudformation\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\n// ProcessDirectory walks all cfn files in a directory, then returns violations\nfunc ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, specFilePath string, skip []string) []shared.Violation {\n\thasFiles, err := scan(directoryPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !hasFiles {\n\t\treturn nil\n\t}\n\n\t// log.Println(\"\\nCloudFormation files found\")\n\tvar allViolations []shared.Violation\n\n\tvar taggable map[string]bool\n\tif specFilePath != \"\" {\n\t\tvar loadSpecErr error\n\t\ttaggable, loadSpecErr = loadTaggableResourcesFromSpec(specFilePath)\n\t\tif loadSpecErr != nil {\n\t\t\tlog.Printf(\"Warning: Could not load or parse --cfn-spec file '%s': %v.\", specFilePath, loadSpecErr)\n\t\t\ttaggable = nil\n\t\t} else {\n\t\t\t// log.Println(\"Parsing CloudFormation spec file.\")\n\t\t}\n\t}\n\n\twalkErr := filepath.Walk(directoryPath, func(path string, info os.FileInfo, walkErr error) error {\n\t\tif walkErr != nil {\n\t\t\tlog.Printf(\"Error accessing %q: %v\\n\", path, walkErr)\n\t\t\treturn walkErr\n\t\t}\n\n\t\tfor _, skipped := range skip {\n\t\t\tif strings.HasPrefix(path, skipped) {\n\t\t\t\tif info.IsDir() {\n\t\t\t\t\treturn filepath.SkipDir\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tdirName := info.Name()\n\t\t\tif slices.Contains(config.SkippedDirs, dirName) {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t}\n\n\t\tif !info.IsDir() && (filepath.Ext(path) == \".yaml\" || filepath.Ext(path) == \".yml\" || filepath.Ext(path) == \".json\") {\n\t\t\tviolations, processErr := processFile(path, requiredTags, caseInsensitive, taggable)\n\t\t\tif processErr != nil {\n\t\t\t\tlog.Printf(\"Error processing file %s: %v\\n\", path, processErr)\n\t\t\t\treturn nil // Example: Continue walking\n\t\t\t}\n\t\t\tallViolations = append(allViolations, violations...)\n\t\t}\n\t\treturn nil\n\t})\n\tif walkErr != nil {\n\t\tlog.Printf(\"Error scanning directory %s: %v\\n\", directoryPath, walkErr)\n\t}\n\treturn allViolations\n}\n\n// processFile parses files and maps the cfn nodes\nfunc processFile(filePath string, requiredTags shared.TagMap, caseInsensitive bool, taggable map[string]bool) ([]shared.Violation, error) {\n\tdata, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\tlog.Printf(\"Error reading %s: %v\\n\", filePath, err)\n\t\treturn nil, fmt.Errorf(\"reading file %s: %w\", filePath, err)\n\t}\n\tcontent := string(data)\n\tlines := strings.Split(content, \"\\n\")\n\n\tskipAll := strings.Contains(content, config.TagNagIgnoreAll)\n\n\troot, err := parseYAML(filePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing file %s: %w\", filePath, err)\n\t}\n\n\t// search root node for resources node\n\tresourcesMapping := mapNodes(findMapNode(root, \"Resources\"))\n\tif resourcesMapping == nil {\n\t\tlog.Printf(\"No 'Resources' section found in %s\\n\", filePath)\n\t\treturn []shared.Violation{}, nil\n\t}\n\n\tviolations := checkResourcesForTags(resourcesMapping, requiredTags, caseInsensitive, lines, skipAll, taggable, filePath)\n\treturn violations, nil\n}\n"
  },
  {
    "path": "internal/cloudformation/resources.go",
    "content": "package cloudformation\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// getResourceViolations inspects resource blocks and returns violations\nfunc checkResourcesForTags(resourcesMapping map[string]*yaml.Node, requiredTags shared.TagMap, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation {\n\tvar violations []shared.Violation\n\n\tfor resourceName, resourceNode := range resourcesMapping { // resourceNode == yaml node for resource\n\t\tresourceMapping := mapNodes(resourceNode)\n\n\t\ttypeNode, ok := resourceMapping[\"Type\"]\n\t\tif !ok || !strings.HasPrefix(typeNode.Value, \"AWS::\") {\n\t\t\tcontinue\n\t\t}\n\t\tresourceType := typeNode.Value\n\n\t\tif taggable != nil {\n\t\t\tisTaggable, found := taggable[resourceType]\n\t\t\tif found && !isTaggable {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tproperties := make(map[string]any) // tags are part of the properties  node\n\t\tif propsNode, ok := resourceMapping[\"Properties\"]; ok {\n\t\t\t_ = propsNode.Decode(&properties)\n\t\t}\n\n\t\ttags, err := extractTagMap(properties, caseInsensitive)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Error extracting tags from resource %s: %v\\n\", resourceName, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tmissing := shared.FilterMissingTags(requiredTags, tags, caseInsensitive)\n\t\tif len(missing) > 0 {\n\t\t\tviolation := shared.Violation{\n\t\t\t\tResourceName: resourceName,\n\t\t\t\tResourceType: resourceType,\n\t\t\t\tLine:         resourceNode.Line,\n\t\t\t\tMissingTags:  missing,\n\t\t\t\tFilePath:     filePath,\n\t\t\t}\n\t\t\t// if file-level or resource-level ignore is found\n\t\t\tif skipAll || skipResource(resourceNode, fileLines) {\n\t\t\t\tviolation.Skip = true\n\t\t\t}\n\t\t\tviolations = append(violations, violation)\n\t\t}\n\t}\n\treturn violations\n}\n\n// extractTagMap extracts a yaml/json map to a go map\nfunc extractTagMap(properties map[string]any, caseInsensitive bool) (shared.TagMap, error) {\n\ttagsMap := make(shared.TagMap)\n\tliteralTags, exists := properties[\"Tags\"]\n\tif !exists {\n\t\treturn tagsMap, nil\n\t}\n\n\ttagsList, ok := literalTags.([]any)\n\tif !ok {\n\t\treturn tagsMap, fmt.Errorf(\"Tags format is invalid\") // tags are not in a list\n\t}\n\n\tfor _, tagInterface := range tagsList {\n\t\ttagEntry, ok := tagInterface.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tkey, ok := tagEntry[\"Key\"].(string)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tvar tagValue string\n\t\tif valStr, ok := tagEntry[\"Value\"].(string); ok {\n\t\t\ttagValue = valStr\n\t\t} else if refMap, ok := tagEntry[\"Value\"].(map[string]any); ok {\n\t\t\tif ref, exists := refMap[\"Ref\"]; exists {\n\t\t\t\tif refStr, ok := ref.(string); ok {\n\t\t\t\t\ttagValue = fmt.Sprintf(\"!Ref %s\", refStr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tkey = shared.NormalizeCase(key, caseInsensitive)\n\t\ttagsMap[key] = []string{tagValue}\n\t}\n\treturn tagsMap, nil\n}\n"
  },
  {
    "path": "internal/cloudformation/resources_test.go",
    "content": "package cloudformation\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestExtractTagMap(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tproperties      map[string]any\n\t\tcaseInsensitive bool\n\t\texpected        shared.TagMap\n\t\texpectedErr     bool\n\t}{\n\t\t{\n\t\t\tname: \"no tag key\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"OtherKey\": \"Value\"},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty list\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{},\n\t\t\t},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t\t{\n\t\t\tname: \"not a list\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": map[string]string{\"Key\": \"Value\"},\n\t\t\t},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"literal tags\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{\n\t\t\t\t\tmap[string]any{\"Key\": \"Owner\", \"Value\": \"Jake\"},\n\t\t\t\t\tmap[string]any{\"Key\": \"Env\", \"Value\": \"Dev\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\": []string{\"Jake\"},\n\t\t\t\t\"Env\":   []string{\"Dev\"},\n\t\t\t},\n\t\t},\n\t\t// {\n\t\t// \tname: \"referenced tags\",\n\t\t// \tproperties: map[string]any{\n\t\t// \t\t\"Tags\": []any{\n\t\t// \t\t\tmap[string]any{\"Key\": \"StackName\", \"Value\": map[string]any{\"Ref\": \"AWS::StackName\"}},\n\t\t// \t\t},\n\t\t// \t},\n\t\t// \texpected: shared.TagMap{\n\t\t// \t\t\"StackName\": []string{\"!Ref StackName\"},\n\t\t// \t},\n\t\t// },\n\t\t// {\n\t\t// \tname: \"mixed tags, literal and referenced\",\n\t\t// \tproperties: map[string]any{\n\t\t// \t\t\"Tags\": []any{\n\t\t// \t\t\tmap[string]any{\"Key\": \"Owner\", \"Value\": \"Jake\"},\n\t\t// \t\t\tmap[string]any{\"Key\": \"StackName\", \"Value\": map[string]any{\"Ref\": \"AWS::StackName\"}},\n\t\t// \t\t},\n\t\t// \t},\n\t\t// \texpected: shared.TagMap{\n\t\t// \t\t\"Owner\":     []string{\"Jake\"},\n\t\t// \t\t\"StackName\": []string{\"!Ref StackName\"},\n\t\t// \t},\n\t\t// },\n\t\t{\n\t\t\tname: \"literal tags, case insensitive\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{\n\t\t\t\t\tmap[string]any{\"Key\": \"Owner\", \"Value\": \"Jake\"},\n\t\t\t\t\tmap[string]any{\"Key\": \"env\", \"Value\": \"Dev\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tcaseInsensitive: true,\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"owner\": []string{\"Jake\"},\n\t\t\t\t\"env\":   []string{\"Dev\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"missing key\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{\n\t\t\t\t\tmap[string]any{\"Value\": \"Jake\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t\t{\n\t\t\tname: \"missing value\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{\n\t\t\t\t\tmap[string]any{\"Key\": \"OptionalTag\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"OptionalTag\": []string{\"\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"non-string\",\n\t\t\tproperties: map[string]any{\n\t\t\t\t\"Tags\": []any{\n\t\t\t\t\tmap[string]any{\"Key\": 123, \"Value\": \"Jake\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot, err := extractTagMap(tc.properties, tc.caseInsensitive)\n\t\t\tif (err != nil) != tc.expectedErr {\n\t\t\t\tt.Fatalf(\"extractTagMap() error = %v, expectedErr %v\", err, tc.expectedErr)\n\t\t\t}\n\t\t\tif !tc.expectedErr {\n\t\t\t\tif diff := cmp.Diff(tc.expected, got); diff != \"\" {\n\t\t\t\t\tt.Errorf(\"extractTagMap() mismatch (-expected +got):\\n%s\", diff)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/cloudformation/scan.go",
    "content": "package cloudformation\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"path/filepath\"\n)\n\n// scan looks for cfn files\nfunc scan(directoryPath string) (bool, error) {\n\tfound := false\n\ttargetExts := map[string]bool{\n\t\t\".yaml\": true,\n\t\t\".yml\":  true,\n\t\t\".json\": true,\n\t}\n\twalkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tif !d.IsDir() {\n\t\t\tif targetExts[filepath.Ext(path)] {\n\t\t\t\tfound = true\n\t\t\t\treturn fs.ErrNotExist // stop scan immediately\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {\n\t\treturn false, walkErr\n\t}\n\treturn found, nil\n}\n"
  },
  {
    "path": "internal/cloudformation/spec_loader.go",
    "content": "package cloudformation\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n)\n\ntype cfnSpec struct {\n\tResourceTypes map[string]cfnResourceType `json:\"ResourceTypes\"`\n\tPropertyTypes map[string]cfnPropertyType `json:\"PropertyTypes\"`\n}\n\ntype cfnResourceType struct {\n\tProperties map[string]cfnProperty `json:\"Properties\"`\n}\n\ntype cfnProperty struct {\n\tRequired          bool   `json:\"Required\"`\n\tType              string `json:\"Type\"`     // list\n\tItemType          string `json:\"ItemType\"` // tag\n\tPrimitiveType     string `json:\"PrimitiveType\"`\n\tPrimitiveItemType string `json:\"PrimitiveItemType\"`\n\t// Other fields ignored\n}\n\ntype cfnPropertyType struct {\n\tProperties map[string]cfnProperty `json:\"Properties\"`\n}\n\n// isTaggable checks the cfn spec file to see if a resource can be tagged\nfunc (rt cfnResourceType) isTaggable(specData *cfnSpec) bool {\n\ttagsProp, ok := rt.Properties[\"Tags\"]\n\tif !ok {\n\t\treturn false\n\t}\n\tif tagsProp.Type == \"List\" && tagsProp.ItemType == \"Tag\" {\n\t\ttagTypeDef, tagTypeExists := specData.PropertyTypes[\"Tag\"]\n\t\tif tagTypeExists {\n\t\t\t_, keyExists := tagTypeDef.Properties[\"Key\"]\n\t\t\t_, valueExists := tagTypeDef.Properties[\"Value\"]\n\t\t\treturn keyExists && valueExists\n\t\t}\n\t}\n\treturn false\n}\n\n// LoadTaggableResourcesFromSpec parses the provided spec file path\nfunc loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool, error) {\n\tlog.Printf(\"Attempting to load CloudFormation specification from: %s\", specFilePath)\n\tdata, err := os.ReadFile(specFilePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read CloudFormation spec file '%s': %w\", specFilePath, err)\n\t}\n\n\tvar specData cfnSpec\n\terr = json.Unmarshal(data, &specData)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse CloudFormation spec JSON from '%s': %w\", specFilePath, err)\n\t}\n\n\tif specData.ResourceTypes == nil {\n\t\treturn nil, fmt.Errorf(\"invalid CloudFormation spec format: missing 'ResourceTypes' in '%s'\", specFilePath)\n\t}\n\tif specData.PropertyTypes == nil {\n\t\treturn nil, fmt.Errorf(\"invalid CloudFormation spec format: missing 'PropertyTypes' in '%s'\", specFilePath)\n\t}\n\n\ttaggableMap := make(map[string]bool)\n\tfor resourceName, resourceDef := range specData.ResourceTypes {\n\t\tif strings.HasPrefix(resourceName, \"AWS::\") {\n\t\t\ttaggableMap[resourceName] = resourceDef.isTaggable(&specData)\n\t\t}\n\t}\n\n\tlog.Printf(\"Loaded %d AWS resource types.\", len(taggableMap))\n\treturn taggableMap, nil\n}\n"
  },
  {
    "path": "internal/cloudformation/spec_loader_test.go",
    "content": "package cloudformation\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\n// helper to create a test spec file\nfunc createTestSpec() *cfnSpec {\n\tspecJSON := `{\n\t\t\"PropertyTypes\": {\n\t\t\t\"Tag\": {\n\t\t\t\t\"Properties\": {\n\t\t\t\t\t\"Key\": { \"PrimitiveType\": \"String\", \"Required\": true },\n\t\t\t\t\t\"Value\": { \"PrimitiveType\": \"String\", \"Required\": true }\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"OtherType\": {\n\t\t\t\t\"Properties\": { \"Name\": { \"PrimitiveType\": \"String\" } }\n\t\t\t}\n\t\t},\n\t\t\"ResourceTypes\": {}\n\t}`\n\tvar spec cfnSpec\n\tif err := json.Unmarshal([]byte(specJSON), &spec); err != nil {\n\t\tpanic(\"Failed to unmarshal base test spec data: \" + err.Error())\n\t}\n\treturn &spec\n}\n\nfunc TestIsTaggable(t *testing.T) {\n\tbaseSpec := createTestSpec()\n\n\ttests := []struct {\n\t\tname         string\n\t\tresourceJSON string\n\t\twant         bool\n\t}{\n\t\t{\n\t\t\tname: \"taggable\",\n\t\t\tresourceJSON: `{\n\t\t\t\t\"Properties\": {\n\t\t\t\t\t\"Name\": { \"PrimitiveType\": \"String\" },\n\t\t\t\t\t\"Tags\": { \"Type\": \"List\", \"ItemType\": \"Tag\", \"Required\": false }\n\t\t\t\t}\n\t\t\t}`,\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"not taggable\",\n\t\t\tresourceJSON: `{\n\t\t\t\t\"Properties\": {\n\t\t\t\t\t\"Name\": { \"PrimitiveType\": \"String\" }\n\t\t\t\t}\n\t\t\t}`,\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar resourceDef cfnResourceType\n\t\t\tif err := json.Unmarshal([]byte(tc.resourceJSON), &resourceDef); err != nil {\n\t\t\t\tt.Fatalf(\"Failed to unmarshal test resource JSON: %v\", err)\n\t\t\t}\n\n\t\t\tif got := resourceDef.isTaggable(baseSpec); got != tc.want {\n\t\t\t\tt.Errorf(\"isTaggable() = %v, want %v\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "package config\n\nimport (\n\t\"github.com/zclconf/go-cty/cty/function\"\n\t\"github.com/zclconf/go-cty/cty/function/stdlib\"\n)\n\nconst (\n\tTagNagIgnore       = \"#tag-nag ignore\"\n\tTagNagIgnoreAll    = \"#tag-nag ignore-all\"\n\tDefaultConfigFile  = \".tag-nag.yml\"\n\tAltConfigFile      = \".tag-nag.yaml\"\n)\n\nvar SkippedDirs = []string{\n\t\".terraform\",\n\t\".git\",\n}\n\n// terraform functions, used when evaluating context of locals and vars\n// added manually, no reasonable workaround to auto-import all\n// https://developer.hashicorp.com/terraform/language/functions\n// https://pkg.go.dev/github.com/zclconf/go-cty@v1.16.2/cty/function/stdlib\nvar StdlibFuncs = map[string]function.Function{\n\t\"abs\":          stdlib.AbsoluteFunc,\n\t\"ceil\":         stdlib.CeilFunc,\n\t\"chomp\":        stdlib.ChompFunc,\n\t\"chunklist\":    stdlib.ChunklistFunc,\n\t\"coalesce\":     stdlib.CoalesceFunc,\n\t\"coalescelist\": stdlib.CoalesceListFunc,\n\t\"compact\":      stdlib.CompactFunc,\n\t\"concat\":       stdlib.ConcatFunc,\n\t\"contains\":     stdlib.ContainsFunc,\n\t\"csvdecode\":    stdlib.CSVDecodeFunc,\n\t\"distinct\":     stdlib.DistinctFunc,\n\t\"element\":      stdlib.ElementFunc,\n\t\"flatten\":      stdlib.FlattenFunc,\n\t\"floor\":        stdlib.FloorFunc,\n\t\"format\":       stdlib.FormatFunc,\n\t\"formatdate\":   stdlib.FormatDateFunc,\n\t\"formatlist\":   stdlib.FormatListFunc,\n\t\"indent\":       stdlib.IndentFunc,\n\t\"index\":        stdlib.IndexFunc,\n\t\"int\":          stdlib.IntFunc,\n\t\"join\":         stdlib.JoinFunc,\n\t\"jsondecode\":   stdlib.JSONDecodeFunc,\n\t\"jsonencode\":   stdlib.JSONEncodeFunc,\n\t\"keys\":         stdlib.KeysFunc,\n\t\"length\":       stdlib.LengthFunc,\n\t\"log\":          stdlib.LogFunc,\n\t\"lookup\":       stdlib.LookupFunc,\n\t\"lower\":        stdlib.LowerFunc,\n\t\"max\":          stdlib.MaxFunc,\n\t\"merge\":        stdlib.MergeFunc,\n\t\"min\":          stdlib.MinFunc,\n\t\"parseint\":     stdlib.ParseIntFunc,\n\t\"pow\":          stdlib.PowFunc,\n\t\"range\":        stdlib.RangeFunc,\n\t\"regex\":        stdlib.RegexFunc,\n\t\"regexall\":     stdlib.RegexAllFunc,\n\t\"regexreplace\": stdlib.RegexReplaceFunc,\n\t\"replace\":      stdlib.ReplaceFunc,\n\t\"reverse\":      stdlib.ReverseFunc,\n\t\"reverselist\":  stdlib.ReverseListFunc,\n\t\"setunion\":     stdlib.SetUnionFunc,\n\t\"slice\":        stdlib.SliceFunc,\n\t\"sort\":         stdlib.SortFunc,\n\t\"split\":        stdlib.SplitFunc,\n\t\"trim\":         stdlib.TrimFunc,\n\t\"trimprefix\":   stdlib.TrimPrefixFunc,\n\t\"trimspace\":    stdlib.TrimSpaceFunc,\n\t\"trimsuffix\":   stdlib.TrimSuffixFunc,\n\t\"upper\":        stdlib.UpperFunc,\n\t\"values\":       stdlib.ValuesFunc,\n}\n"
  },
  {
    "path": "internal/inputs/inputs.go",
    "content": "package inputs\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/spf13/pflag\"\n)\n\ntype UserInput struct {\n\tDirectory       string\n\tRequiredTags    shared.TagMap\n\tCaseInsensitive bool\n\tDryRun          bool\n\tCfnSpecPath     string\n\tSkip            []string\n\tOutputFormat    shared.OutputFormat\n}\n\n// ParseFlags returns pased CLI flags and arguments\nfunc ParseFlags() UserInput {\n\tvar caseInsensitive bool\n\tvar dryRun bool\n\tvar tags string\n\tvar cfnSpecPath string\n\tvar skip string\n\tvar outputFormat string\n\n\tpflag.BoolVarP(&caseInsensitive, \"case-insensitive\", \"c\", false, \"Make tag checks non-case-sensitive\")\n\tpflag.BoolVarP(&dryRun, \"dry-run\", \"d\", false, \"Dry run tag:nag without triggering exit(1) code\")\n\tpflag.StringVar(&tags, \"tags\", \"\", \"Comma-separated list of required tag keys (e.g., 'Owner,Environment[Dev,Prod]')\")\n\tpflag.StringVar(&cfnSpecPath, \"cfn-spec\", \"\", \"Optional path to CloudFormationResourceSpecification.json)\")\n\tpflag.StringVarP(&skip, \"skip\", \"s\", \"\", \"Comma-separated list of files or directories to skip\")\n\tpflag.StringVarP(&outputFormat, \"output\", \"o\", \"text\", \"Output format: text, json, junit-xml, or sarif\")\n\tpflag.Parse()\n\n\tif pflag.NArg() < 1 {\n\t\tlog.Fatal(\"Error: specify a directory or file to scan\")\n\t}\n\n\t// try config file if no tags provided\n\tif tags == \"\" {\n\t\tconfigFile, err := FindAndLoadConfigFile()\n\t\tif err != nil {\n\t\t\tlog.Fatalf(\"Error loading config: %v\", err)\n\t\t}\n\t\tif configFile != nil {\n\t\t\t// Use config output format if CLI wasn't specified\n\t\t\tconfigOutputFormat := shared.OutputFormat(outputFormat)\n\t\t\toutputFlag := pflag.Lookup(\"output\")\n\t\t\tif !outputFlag.Changed && configFile.Settings.Output != \"\" {\n\t\t\t\tconfigOutputFormat = configFile.Settings.Output\n\t\t\t}\n\t\t\treturn UserInput{\n\t\t\t\tDirectory:       pflag.Arg(0),\n\t\t\t\tRequiredTags:    configFile.convertToTagMap(),\n\t\t\t\tCaseInsensitive: configFile.Settings.CaseInsensitive,\n\t\t\t\tDryRun:          configFile.Settings.DryRun,\n\t\t\t\tCfnSpecPath:     configFile.Settings.CfnSpec,\n\t\t\t\tSkip:            configFile.Skip,\n\t\t\t\tOutputFormat:    configOutputFormat,\n\t\t\t}\n\t\t}\n\t\tlog.Fatal(\"Error: specify required tags using --tags or create a .tag-nag.yml config file\")\n\t}\n\n\tparsedTags, err := parseTags(tags)\n\tif err != nil {\n\t\tlog.Fatalf(\"Error parsing tags: %v\", err)\n\t}\n\n\tvar skipPaths []string\n\tif skip != \"\" {\n\t\tskipPaths = strings.Split(skip, \",\")\n\t\tfor i := range skipPaths {\n\t\t\tskipPaths[i] = strings.TrimSpace(skipPaths[i])\n\t\t}\n\t}\n\n\t// Try to load config file for output format default\n\tconfigFile, err := FindAndLoadConfigFile()\n\tif err != nil && !os.IsNotExist(err) {\n\t\tlog.Fatalf(\"Error loading config: %v\", err)\n\t}\n\n\tformat := shared.OutputFormat(outputFormat)\n\t// Use config output format if CLI wasn't explicitly provided and config exists\n\toutputFlag := pflag.Lookup(\"output\")\n\tif !outputFlag.Changed && configFile != nil && configFile.Settings.Output != \"\" {\n\t\tformat = configFile.Settings.Output\n\t}\n\n\tif format != shared.OutputFormatText && format != shared.OutputFormatJSON && format != shared.OutputFormatJUnitXML && format != shared.OutputFormatSARIF {\n\t\tlog.Fatalf(\"Invalid output format '%s'. Supported formats: text, json, junit-xml, sarif\", outputFormat)\n\t}\n\n\treturn UserInput{\n\t\tDirectory:       pflag.Arg(0),\n\t\tRequiredTags:    parsedTags,\n\t\tCaseInsensitive: caseInsensitive,\n\t\tDryRun:          dryRun,\n\t\tCfnSpecPath:     cfnSpecPath,\n\t\tSkip:            skipPaths,\n\t\tOutputFormat:    format,\n\t}\n}\n\n// parses tag input components\nfunc parseTags(input string) (shared.TagMap, error) {\n\ttagMap := make(shared.TagMap)\n\tpairs := splitTags(input)\n\tfor _, pair := range pairs {\n\t\ttrimmed := strings.TrimSpace(pair)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tkey, values, err := parseTag(trimmed)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to parse tag component '%s': %w\", trimmed, err)\n\t\t}\n\t\ttagMap[key] = values\n\t}\n\treturn tagMap, nil\n}\n\n// parses tag keys and values\nfunc parseTag(tagComponent string) (key string, values []string, err error) {\n\ttrimmed := strings.TrimSpace(tagComponent)\n\tif trimmed == \"\" {\n\t\treturn \"\", nil, fmt.Errorf(\"empty tag component\")\n\t}\n\n\t// key and value\n\topenBracketIdx := strings.Index(trimmed, \"[\")\n\tif openBracketIdx != -1 {\n\t\tif !strings.HasSuffix(trimmed, \"]\") {\n\t\t\treturn \"\", nil, fmt.Errorf(\"invalid tag format: %s. Expected closing ']'\", trimmed)\n\t\t}\n\n\t\tkey = strings.TrimSpace(trimmed[:openBracketIdx])\n\t\tif key == \"\" {\n\t\t\treturn \"\", nil, fmt.Errorf(\"empty key in bracket format: %s\", trimmed)\n\t\t}\n\n\t\tvaluesStr := trimmed[openBracketIdx+1 : len(trimmed)-1]\n\t\tif valuesStr == \"\" {\n\t\t\treturn key, []string{}, nil\n\t\t}\n\n\t\tvalParts := strings.Split(valuesStr, \",\")\n\t\tfor _, v := range valParts {\n\t\t\ttrimmedVal := strings.TrimSpace(v)\n\t\t\tif trimmedVal != \"\" {\n\t\t\t\tvalues = append(values, trimmedVal)\n\t\t\t}\n\t\t}\n\t\treturn key, values, nil\n\t}\n\n\t// key only\n\tif strings.Contains(trimmed, \"[\") || strings.Contains(trimmed, \"]\") {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid tag format: %s. Contains '[' or ']' without matching pair or value definition\", trimmed)\n\t}\n\treturn trimmed, []string{}, nil\n}\n\n// splitTags splits the input string on commas outside of brackets\n// to fix the [a,b,c] issue\nfunc splitTags(input string) []string {\n\tvar parts []string\n\tstart := 0\n\tdepth := 0\n\tfor i, r := range input {\n\t\tswitch r {\n\t\tcase '[':\n\t\t\tdepth++\n\t\tcase ']':\n\t\t\tif depth > 0 {\n\t\t\t\tdepth--\n\t\t\t}\n\t\tcase ',':\n\t\t\tif depth == 0 {\n\t\t\t\tparts = append(parts, strings.TrimSpace(input[start:i]))\n\t\t\t\tstart = i + 1\n\t\t\t}\n\t\t}\n\t}\n\tparts = append(parts, strings.TrimSpace(input[start:]))\n\treturn parts\n}\n"
  },
  {
    "path": "internal/inputs/inputs_test.go",
    "content": "package inputs\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestParseTags(t *testing.T) {\n\ttestCases := []struct {\n\t\tname          string\n\t\tinput         string\n\t\texpected      shared.TagMap\n\t\texpectedError bool\n\t}{\n\t\t{\n\t\t\tname:  \"key\",\n\t\t\tinput: \"Owner\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\": {},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"multiple keys\",\n\t\t\tinput: \"Owner, Environment , Project\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":       {},\n\t\t\t\t\"Environment\": {},\n\t\t\t\t\"Project\":     {},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"mixed keys and values\",\n\t\t\tinput: \"Owner[jake], Environment[Dev,Prod], CostCenter\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":       {\"jake\"},\n\t\t\t\t\"Environment\": {\"Dev\", \"Prod\"},\n\t\t\t\t\"CostCenter\":  {},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"empty\",\n\t\t\tinput:         \"\",\n\t\t\texpected:      shared.TagMap{},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"whitespace\",\n\t\t\tinput:         \"  ,   \",\n\t\t\texpected:      shared.TagMap{},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"mixed keys and values, with whitespace\",\n\t\t\tinput: \" Owner ,  Environment[Dev, Prod] \",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":       {},\n\t\t\t\t\"Environment\": {\"Dev\", \"Prod\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"leading comma\",\n\t\t\tinput: \",Owner\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\": {},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"missing value\",\n\t\t\tinput: \"Env[]\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Env\": {}, // No values extracted\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"missing value, other values present\",\n\t\t\tinput: \"Env[Dev,,Prod]\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Env\": {\"Dev\", \"Prod\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:  \"whitespace preserved\",\n\t\t\tinput: \"Owner[it belongs to me]\",\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\": {\"it belongs to me\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"unclosed bracket\",\n\t\t\tinput:         \"invalid[value\",\n\t\t\texpected:      nil,\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"no key\",\n\t\t\tinput:         \"[value]\",\n\t\t\texpected:      nil,\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"stray bracket\",\n\t\t\tinput:         \"stray]\",\n\t\t\texpected:      nil,\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual, err := parseTags(tc.input)\n\t\t\tif tc.expectedError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"parseTags(%q) expected an error, but got nil\", tc.input)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"parseTags(%q) expected no error, but got: %v\", tc.input, err)\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(actual, tc.expected) {\n\t\t\t\tt.Errorf(\"parseTags(%q) = %v; want %v\", tc.input, actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSplitTags(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected []string\n\t}{\n\t\t{\"empty\", \"\", []string{\"\"}},\n\t\t{\"key\", \"Owner\", []string{\"Owner\"}},\n\t\t{\"multiple keys\", \"Owner, Env , Project\", []string{\"Owner\", \"Env\", \"Project\"}},\n\t\t{\"value\", \"Owner[Jake]\", []string{\"Owner[Jake]\"}},\n\t\t{\"multiple values\", \"Env[Dev,Prod]\", []string{\"Env[Dev,Prod]\"}},\n\t\t{\"mixed keys and values\", \"Owner[Jake], Env[Dev,Prod], CostCenter\", []string{\"Owner[Jake]\", \"Env[Dev,Prod]\", \"CostCenter\"}},\n\t\t{\"trailing comma\", \"Owner,Env,\", []string{\"Owner\", \"Env\", \"\"}},\n\t\t{\"leading comma\", \",Owner,Env\", []string{\"\", \"Owner\", \"Env\"}},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := splitTags(tc.input)\n\t\t\tif !reflect.DeepEqual(actual, tc.expected) {\n\t\t\t\tt.Errorf(\"splitTags(%q) = %v; want %v\", tc.input, actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/inputs/loader.go",
    "content": "package inputs\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype Config struct {\n\tTags     []TagDefinition `yaml:\"tags\"`\n\tSettings Settings        `yaml:\"settings\"`\n\tSkip     []string        `yaml:\"skip\"`\n}\n\ntype TagDefinition struct {\n\tKey    string   `yaml:\"key\"`\n\tValues []string `yaml:\"values,omitempty\"`\n}\n\ntype Settings struct {\n\tCaseInsensitive bool                `yaml:\"case_insensitive\"`\n\tDryRun          bool                `yaml:\"dry_run\"`\n\tCfnSpec         string              `yaml:\"cfn_spec\"`\n\tOutput          shared.OutputFormat `yaml:\"output\"`\n}\n\n// FindAndLoadConfigFile attempts to find and load configuration file\nfunc FindAndLoadConfigFile() (*Config, error) {\n\tif _, err := os.Stat(config.DefaultConfigFile); err == nil {\n\t\treturn processConfigFile(config.DefaultConfigFile)\n\t}\n\n\tif _, err := os.Stat(config.AltConfigFile); err == nil {\n\t\treturn processConfigFile(config.AltConfigFile)\n\t}\n\n\treturn nil, nil\n}\n\n// processConfigFile reads the config file\nfunc processConfigFile(path string) (*Config, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading config file %s: %w\", path, err)\n\t}\n\n\tvar config Config\n\tif err := yaml.Unmarshal(data, &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing config file %s: %w\", path, err)\n\t}\n\n\treturn &config, nil\n}\n\n// ConvertToTagMap converts config tags to internal TagMap format\nfunc (c *Config) convertToTagMap() shared.TagMap {\n\ttagMap := make(shared.TagMap)\n\n\tfor _, tag := range c.Tags {\n\t\ttagMap[tag.Key] = tag.Values\n\t}\n\n\treturn tagMap\n}\n"
  },
  {
    "path": "internal/inputs/loader_test.go",
    "content": "package inputs\n\nimport (\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestProcessConfigFile(t *testing.T) {\n\ttestCases := []struct {\n\t\tname              string\n\t\tconfigFile        string\n\t\texpectedError     bool\n\t\texpectedTags      int\n\t\texpectedOwner     bool\n\t\texpectedEnvValues []string\n\t\texpectedSettings  Settings\n\t\texpectedSkips     []string\n\t}{\n\t\t{\n\t\t\tname:          \"tag keys\",\n\t\t\tconfigFile:    \"../../testdata/config/tag_keys.yml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  2,\n\t\t\texpectedOwner: true,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{},\n\t\t},\n\t\t{\n\t\t\tname:              \"tag values\",\n\t\t\tconfigFile:        \"../../testdata/config/tag_values.yml\",\n\t\t\texpectedError:     false,\n\t\t\texpectedTags:      3,\n\t\t\texpectedOwner:     true,\n\t\t\texpectedEnvValues: []string{\"Dev\", \"Test\", \"Prod\"},\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{},\n\t\t},\n\t\t{\n\t\t\tname:          \"full config\",\n\t\t\tconfigFile:    \"../../testdata/config/full_config.yml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  3,\n\t\t\texpectedOwner: true,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: true,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"/path/to/spec.json\",\n\t\t\t},\n\t\t\texpectedSkips: []string{\"*.tmp\", \".terraform\", \"test-data/**\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"empty settings\",\n\t\t\tconfigFile:    \"../../testdata/config/empty_settings.yml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  1,\n\t\t\texpectedOwner: true,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{},\n\t\t},\n\t\t{\n\t\t\tname:          \"yaml extension\",\n\t\t\tconfigFile:    \"../../testdata/config/yaml_extension.yaml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  1,\n\t\t\texpectedOwner: true,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{},\n\t\t},\n\t\t{\n\t\t\tname:          \"blank config\",\n\t\t\tconfigFile:    \"../../testdata/config/blank_config.yml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  0,\n\t\t\texpectedOwner: false,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          false,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{},\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid syntax\",\n\t\t\tconfigFile:    \"../../testdata/config/invalid_syntax.yml\",\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"missing tags\",\n\t\t\tconfigFile:    \"../../testdata/config/missing_tags.yml\",\n\t\t\texpectedError: false,\n\t\t\texpectedTags:  0,\n\t\t\texpectedOwner: false,\n\t\t\texpectedSettings: Settings{\n\t\t\t\tCaseInsensitive: false,\n\t\t\t\tDryRun:          true,\n\t\t\t\tCfnSpec:         \"\",\n\t\t\t},\n\t\t\texpectedSkips: []string{\"*.tmp\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid structure\",\n\t\t\tconfigFile:    \"../../testdata/config/invalid_structure.yml\",\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid tag value array\",\n\t\t\tconfigFile:    \"../../testdata/config/tag_array.yml\",\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"no file\",\n\t\t\tconfigFile:    \"../../testdata/config/does-not-exist.yml\",\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tconfig, err := processConfigFile(tc.configFile)\n\n\t\t\t// Check error expectation\n\t\t\tif tc.expectedError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif config == nil {\n\t\t\t\tt.Errorf(\"Expected config but got nil\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Check number of tags\n\t\t\tif len(config.Tags) != tc.expectedTags {\n\t\t\t\tt.Errorf(\"Expected %d tags, got %d\", tc.expectedTags, len(config.Tags))\n\t\t\t}\n\n\t\t\t// Check if Owner tag exists\n\t\t\thasOwner := false\n\t\t\tvar envValues []string\n\t\t\tfor _, tag := range config.Tags {\n\t\t\t\tif tag.Key == \"Owner\" {\n\t\t\t\t\thasOwner = true\n\t\t\t\t}\n\t\t\t\tif tag.Key == \"Environment\" {\n\t\t\t\t\tenvValues = tag.Values\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasOwner != tc.expectedOwner {\n\t\t\t\tt.Errorf(\"Expected Owner tag: %v, got: %v\", tc.expectedOwner, hasOwner)\n\t\t\t}\n\n\t\t\t// Check Environment values if specified\n\t\t\tif tc.expectedEnvValues != nil {\n\t\t\t\tif !reflect.DeepEqual(envValues, tc.expectedEnvValues) {\n\t\t\t\t\tt.Errorf(\"Expected Environment values %v, got %v\", tc.expectedEnvValues, envValues)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check settings\n\t\t\tif config.Settings != tc.expectedSettings {\n\t\t\t\tt.Errorf(\"Expected settings %+v, got %+v\", tc.expectedSettings, config.Settings)\n\t\t\t}\n\n\t\t\t// Check skip patterns (handle nil vs empty slice)\n\t\t\tconfigSkips := config.Skip\n\t\t\tif configSkips == nil {\n\t\t\t\tconfigSkips = []string{}\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(configSkips, tc.expectedSkips) {\n\t\t\t\tt.Errorf(\"Expected skip patterns %v, got %v\", tc.expectedSkips, configSkips)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindAndLoadConfigFile(t *testing.T) {\n\t// Save current directory\n\toriginalDir, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to get current directory: %v\", err)\n\t}\n\tdefer os.Chdir(originalDir)\n\n\ttestCases := []struct {\n\t\tname           string\n\t\tsetupFunc      func(t *testing.T, tempDir string)\n\t\texpectedError  bool\n\t\texpectedConfig bool\n\t\texpectedTags   int\n\t}{\n\t\t{\n\t\t\tname: \"no config files present\",\n\t\t\tsetupFunc: func(t *testing.T, tempDir string) {\n\t\t\t\t// Empty directory\n\t\t\t},\n\t\t\texpectedError:  false,\n\t\t\texpectedConfig: false,\n\t\t},\n\t\t{\n\t\t\tname: \".tag-nag.yml present\",\n\t\t\tsetupFunc: func(t *testing.T, tempDir string) {\n\t\t\t\tcontent := \"tags:\\n  - key: Owner\\n\"\n\t\t\t\terr := os.WriteFile(\".tag-nag.yml\", []byte(content), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create test config: %v\", err)\n\t\t\t\t}\n\t\t\t},\n\t\t\texpectedError:  false,\n\t\t\texpectedConfig: true,\n\t\t\texpectedTags:   1,\n\t\t},\n\t\t{\n\t\t\tname: \".tag-nag.yaml present\",\n\t\t\tsetupFunc: func(t *testing.T, tempDir string) {\n\t\t\t\tcontent := \"tags:\\n  - key: Project\\n\"\n\t\t\t\terr := os.WriteFile(\".tag-nag.yaml\", []byte(content), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create test config: %v\", err)\n\t\t\t\t}\n\t\t\t},\n\t\t\texpectedError:  false,\n\t\t\texpectedConfig: true,\n\t\t\texpectedTags:   1,\n\t\t},\n\t\t{\n\t\t\tname: \"both files present (should prefer .yml)\",\n\t\t\tsetupFunc: func(t *testing.T, tempDir string) {\n\t\t\t\tymlContent := \"tags:\\n  - key: Owner\\n  - key: Project\\n\"\n\t\t\t\tyamlContent := \"tags:\\n  - key: Environment\\n\"\n\n\t\t\t\terr := os.WriteFile(\".tag-nag.yml\", []byte(ymlContent), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create .yml config: %v\", err)\n\t\t\t\t}\n\n\t\t\t\terr = os.WriteFile(\".tag-nag.yaml\", []byte(yamlContent), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create .yaml config: %v\", err)\n\t\t\t\t}\n\t\t\t},\n\t\t\texpectedError:  false,\n\t\t\texpectedConfig: true,\n\t\t\texpectedTags:   2, // Should use .yml file (2 tags), not .yaml file (1 tag)\n\t\t},\n\t\t{\n\t\t\tname: \"invalid config file\",\n\t\t\tsetupFunc: func(t *testing.T, tempDir string) {\n\t\t\t\tcontent := \"tags:\\n  - key: Owner\\n    values: [invalid\"\n\t\t\t\terr := os.WriteFile(\".tag-nag.yml\", []byte(content), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create invalid config: %v\", err)\n\t\t\t\t}\n\t\t\t},\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Create temporary directory for each test\n\t\t\ttempDir, err := os.MkdirTemp(\"\", \"tag-nag-test\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t\t\t}\n\t\t\tdefer os.RemoveAll(tempDir)\n\n\t\t\t// Change to temp directory\n\t\t\terr = os.Chdir(tempDir)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to change to temp dir: %v\", err)\n\t\t\t}\n\n\t\t\t// Setup test scenario\n\t\t\ttc.setupFunc(t, tempDir)\n\n\t\t\t// Test the function\n\t\t\tconfig, err := FindAndLoadConfigFile()\n\n\t\t\t// Check error expectation\n\t\t\tif tc.expectedError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Check config presence\n\t\t\tif tc.expectedConfig {\n\t\t\t\tif config == nil {\n\t\t\t\t\tt.Errorf(\"Expected config but got nil\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(config.Tags) != tc.expectedTags {\n\t\t\t\t\tt.Errorf(\"Expected %d tags, got %d\", tc.expectedTags, len(config.Tags))\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif config != nil {\n\t\t\t\t\tt.Errorf(\"Expected no config but got: %+v\", config)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertToTagMap(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tconfig   Config\n\t\texpected shared.TagMap\n\t}{\n\t\t{\n\t\t\tname: \"empty config\",\n\t\t\tconfig: Config{\n\t\t\t\tTags: []TagDefinition{},\n\t\t\t},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t\t{\n\t\t\tname: \"tags without values\",\n\t\t\tconfig: Config{\n\t\t\t\tTags: []TagDefinition{\n\t\t\t\t\t{Key: \"Owner\"},\n\t\t\t\t\t{Key: \"Project\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":   []string{},\n\t\t\t\t\"Project\": []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"tags with values\",\n\t\t\tconfig: Config{\n\t\t\t\tTags: []TagDefinition{\n\t\t\t\t\t{Key: \"Owner\"},\n\t\t\t\t\t{Key: \"Environment\", Values: []string{\"Dev\", \"Test\", \"Prod\"}},\n\t\t\t\t\t{Key: \"Project\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":       []string{},\n\t\t\t\t\"Environment\": []string{\"Dev\", \"Test\", \"Prod\"},\n\t\t\t\t\"Project\":     []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed values\",\n\t\t\tconfig: Config{\n\t\t\t\tTags: []TagDefinition{\n\t\t\t\t\t{Key: \"Owner\", Values: []string{\"Alice\", \"Bob\"}},\n\t\t\t\t\t{Key: \"Environment\", Values: []string{\"Prod\"}},\n\t\t\t\t\t{Key: \"Project\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\n\t\t\t\t\"Owner\":       []string{\"Alice\", \"Bob\"},\n\t\t\t\t\"Environment\": []string{\"Prod\"},\n\t\t\t\t\"Project\":     []string{},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := tc.config.convertToTagMap()\n\n\t\t\t// Check each key individually to handle nil vs empty slice differences\n\t\t\tif len(result) != len(tc.expected) {\n\t\t\t\tt.Errorf(\"Expected %d keys, got %d\", len(tc.expected), len(result))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfor key, expectedValues := range tc.expected {\n\t\t\t\tactualValues, exists := result[key]\n\t\t\t\tif !exists {\n\t\t\t\t\tt.Errorf(\"Expected key %s not found in result\", key)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Handle nil vs empty slice\n\t\t\t\tif actualValues == nil {\n\t\t\t\t\tactualValues = []string{}\n\t\t\t\t}\n\t\t\t\tif expectedValues == nil {\n\t\t\t\t\texpectedValues = []string{}\n\t\t\t\t}\n\n\t\t\t\tif !reflect.DeepEqual(actualValues, expectedValues) {\n\t\t\t\t\tt.Errorf(\"Key %s: expected %v, got %v\", key, expectedValues, actualValues)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/output/formatter.go",
    "content": "package output\n\nimport \"github.com/jakebark/tag-nag/internal/shared\"\n\ntype Formatter interface {\n\tFormat(violations []shared.Violation) ([]byte, error)\n}\n\n// GetFormatter returns the appropriate formatter for the given format\nfunc GetFormatter(format shared.OutputFormat) Formatter {\n\tswitch format {\n\tcase shared.OutputFormatJSON:\n\t\treturn &JSONFormatter{}\n\tcase shared.OutputFormatJUnitXML:\n\t\treturn &JUnitXMLFormatter{}\n\tcase shared.OutputFormatSARIF:\n\t\treturn &SARIFFormatter{}\n\tcase shared.OutputFormatText:\n\t\tfallthrough\n\tdefault:\n\t\treturn &TextFormatter{}\n\t}\n}\n\n"
  },
  {
    "path": "internal/output/formatter_test.go",
    "content": "package output\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestGetFormatter(t *testing.T) {\n\ttestCases := []struct {\n\t\tname         string\n\t\tformat       shared.OutputFormat\n\t\texpectedType string\n\t}{\n\t\t{\n\t\t\tname:         \"json format\",\n\t\t\tformat:       shared.OutputFormatJSON,\n\t\t\texpectedType: \"*output.JSONFormatter\",\n\t\t},\n\t\t{\n\t\t\tname:         \"junit-xml format\",\n\t\t\tformat:       shared.OutputFormatJUnitXML,\n\t\t\texpectedType: \"*output.JUnitXMLFormatter\",\n\t\t},\n\t\t{\n\t\t\tname:         \"sarif format\",\n\t\t\tformat:       shared.OutputFormatSARIF,\n\t\t\texpectedType: \"*output.SARIFFormatter\",\n\t\t},\n\t\t{\n\t\t\tname:         \"text format\",\n\t\t\tformat:       shared.OutputFormatText,\n\t\t\texpectedType: \"*output.TextFormatter\",\n\t\t},\n\t\t{\n\t\t\tname:         \"unknown format defaults to text\",\n\t\t\tformat:       shared.OutputFormat(\"unknown\"),\n\t\t\texpectedType: \"*output.TextFormatter\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tformatter := GetFormatter(tc.format)\n\t\t\tactualType := reflect.TypeOf(formatter).String()\n\t\t\tif actualType != tc.expectedType {\n\t\t\t\tt.Errorf(\"GetFormatter(%q) = %s; want %s\", tc.format, actualType, tc.expectedType)\n\t\t\t}\n\t\t})\n\t}\n}\n\n"
  },
  {
    "path": "internal/output/json.go",
    "content": "package output\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\n// JSONFormatter implements JSON output format\ntype JSONFormatter struct{}\n\n// JSONOutput represents the structured JSON output format\ntype JSONOutput struct {\n\tViolations []shared.Violation `json:\"violations\"`\n\tSummary    Summary            `json:\"summary\"`\n}\n\n// Summary provides aggregate information about violations\ntype Summary struct {\n\tTotal         int `json:\"total\"`\n\tSkipped       int `json:\"skipped\"`\n\tFilesAffected int `json:\"files_affected\"`\n}\n\n// Format formats violations as JSON\nfunc (f *JSONFormatter) Format(violations []shared.Violation) ([]byte, error) {\n\tvar skipped int\n\tfiles := make(map[string]bool)\n\n\tfor _, v := range violations {\n\t\tif v.Skip {\n\t\t\tskipped++\n\t\t}\n\t\tfiles[v.FilePath] = true\n\t}\n\n\toutput := JSONOutput{\n\t\tViolations: violations,\n\t\tSummary: Summary{\n\t\t\tTotal:         len(violations),\n\t\t\tSkipped:       skipped,\n\t\t\tFilesAffected: len(files),\n\t\t},\n\t}\n\n\treturn json.MarshalIndent(output, \"\", \"  \")\n}\n\n"
  },
  {
    "path": "internal/output/json_test.go",
    "content": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestJSONFormatter_Format(t *testing.T) {\n\ttestCases := []struct {\n\t\tname       string\n\t\tviolations []shared.Violation\n\t\twantJSON   bool\n\t\twantFields []string\n\t}{\n\t\t{\n\t\t\tname:       \"empty violations\",\n\t\t\tviolations: []shared.Violation{},\n\t\t\twantJSON:   true,\n\t\t\twantFields: []string{\"violations\", \"summary\"},\n\t\t},\n\t\t{\n\t\t\tname: \"single violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{\n\t\t\t\t\tResourceType: \"aws_s3_bucket\",\n\t\t\t\t\tResourceName: \"test\",\n\t\t\t\t\tMissingTags:  []string{\"Owner\"},\n\t\t\t\t\tFilePath:     \"main.tf\",\n\t\t\t\t\tLine:         10,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantJSON:   true,\n\t\t\twantFields: []string{\"violations\", \"summary\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", MissingTags: []string{\"Env\"}},\n\t\t\t},\n\t\t\twantJSON:   true,\n\t\t\twantFields: []string{\"violations\", \"summary\"},\n\t\t},\n\t\t{\n\t\t\tname: \"skipped violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test\", Skip: true},\n\t\t\t},\n\t\t\twantJSON:   true,\n\t\t\twantFields: []string{\"violations\", \"summary\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tformatter := &JSONFormatter{}\n\t\t\toutput, err := formatter.Format(tc.violations)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Format() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tc.wantJSON {\n\t\t\t\tvar parsed map[string]interface{}\n\t\t\t\tif err := json.Unmarshal(output, &parsed); err != nil {\n\t\t\t\t\tt.Errorf(\"Output is not valid JSON: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tfor _, field := range tc.wantFields {\n\t\t\t\t\tif _, exists := parsed[field]; !exists {\n\t\t\t\t\t\tt.Errorf(\"Missing field %q in JSON output\", field)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}"
  },
  {
    "path": "internal/output/junit.go",
    "content": "package output\n\nimport (\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype JUnitXMLFormatter struct{}\n\ntype TestSuite struct {\n\tXMLName   xml.Name   `xml:\"testsuite\"`\n\tName      string     `xml:\"name,attr\"`\n\tTests     int        `xml:\"tests,attr\"`\n\tFailures  int        `xml:\"failures,attr\"`\n\tTestCases []TestCase `xml:\"testcase\"`\n}\n\ntype TestCase struct {\n\tXMLName   xml.Name `xml:\"testcase\"`\n\tName      string   `xml:\"name,attr\"`\n\tClassName string   `xml:\"classname,attr\"`\n\tFailure   *Failure `xml:\"failure,omitempty\"`\n}\n\ntype Failure struct {\n\tXMLName xml.Name `xml:\"failure\"`\n\tMessage string   `xml:\"message,attr\"`\n}\n\n// Format formats violations as JUnit XML\nfunc (f *JUnitXMLFormatter) Format(violations []shared.Violation) ([]byte, error) {\n\tvar testCases []TestCase\n\tfailures := 0\n\n\tfor _, v := range violations {\n\t\ttestCase := TestCase{\n\t\t\tName:      fmt.Sprintf(\"%s.%s\", v.ResourceType, v.ResourceName),\n\t\t\tClassName: v.FilePath,\n\t\t}\n\n\t\tif !v.Skip {\n\t\t\tfailures++\n\t\t\ttestCase.Failure = &Failure{\n\t\t\t\tMessage: fmt.Sprintf(\"Missing tags: %s\", strings.Join(v.MissingTags, \", \")),\n\t\t\t}\n\t\t}\n\n\t\ttestCases = append(testCases, testCase)\n\t}\n\n\ttestSuite := TestSuite{\n\t\tName:      \"tag-nag\",\n\t\tTests:     len(violations),\n\t\tFailures:  failures,\n\t\tTestCases: testCases,\n\t}\n\n\toutput, err := xml.MarshalIndent(testSuite, \"\", \"  \")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn []byte(xml.Header + string(output)), nil\n}\n\n"
  },
  {
    "path": "internal/output/junit_test.go",
    "content": "package output\n\nimport (\n\t\"encoding/xml\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestJUnitXMLFormatter_Format(t *testing.T) {\n\ttestCases := []struct {\n\t\tname         string\n\t\tviolations   []shared.Violation\n\t\twantXML      bool\n\t\twantTests    int\n\t\twantFailures int\n\t}{\n\t\t{\n\t\t\tname:         \"empty violations\",\n\t\t\tviolations:   []shared.Violation{},\n\t\t\twantXML:      true,\n\t\t\twantTests:    0,\n\t\t\twantFailures: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{\n\t\t\t\t\tResourceType: \"aws_s3_bucket\",\n\t\t\t\t\tResourceName: \"test\",\n\t\t\t\t\tMissingTags:  []string{\"Owner\"},\n\t\t\t\t\tFilePath:     \"main.tf\",\n\t\t\t\t\tLine:         10,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantXML:      true,\n\t\t\twantTests:    1,\n\t\t\twantFailures: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", MissingTags: []string{\"Env\"}},\n\t\t\t},\n\t\t\twantXML:      true,\n\t\t\twantTests:    2,\n\t\t\twantFailures: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"skipped violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test\", Skip: true},\n\t\t\t},\n\t\t\twantXML:      true,\n\t\t\twantTests:    1,\n\t\t\twantFailures: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", Skip: true},\n\t\t\t},\n\t\t\twantXML:      true,\n\t\t\twantTests:    2,\n\t\t\twantFailures: 1,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tformatter := &JUnitXMLFormatter{}\n\t\t\toutput, err := formatter.Format(tc.violations)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Format() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tc.wantXML {\n\t\t\t\tvar testSuite TestSuite\n\t\t\t\tif err := xml.Unmarshal(output, &testSuite); err != nil {\n\t\t\t\t\tt.Errorf(\"Output is not valid XML: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif testSuite.Tests != tc.wantTests {\n\t\t\t\t\tt.Errorf(\"Tests count = %d; want %d\", testSuite.Tests, tc.wantTests)\n\t\t\t\t}\n\n\t\t\t\tif testSuite.Failures != tc.wantFailures {\n\t\t\t\t\tt.Errorf(\"Failures count = %d; want %d\", testSuite.Failures, tc.wantFailures)\n\t\t\t\t}\n\n\t\t\t\tif !strings.Contains(string(output), \"<?xml version\") {\n\t\t\t\t\tt.Errorf(\"Output missing XML declaration\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}"
  },
  {
    "path": "internal/output/process.go",
    "content": "package output\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\n// ProcessOutput handles the output formatting and exit logic\nfunc ProcessOutput(violations []shared.Violation, format shared.OutputFormat, dryRun bool) {\n\tformatter := GetFormatter(format)\n\tformattedOutput, err := formatter.Format(violations)\n\tif err != nil {\n\t\tlog.Fatalf(\"Error formatting output: %v\", err)\n\t}\n\n\tif len(formattedOutput) > 0 {\n\t\tfmt.Print(string(formattedOutput))\n\t}\n\n\tnonSkippedCount := 0\n\tfor _, v := range violations {\n\t\tif !v.Skip {\n\t\t\tnonSkippedCount++\n\t\t}\n\t}\n\n\tif nonSkippedCount > 0 && dryRun {\n\t\tlog.Printf(\"\\033[32mFound %d tag violation(s)\\033[0m\\n\", nonSkippedCount)\n\t\tos.Exit(0)\n\t} else if nonSkippedCount > 0 {\n\t\tlog.Printf(\"\\033[31mFound %d tag violation(s)\\033[0m\\n\", nonSkippedCount)\n\t\tos.Exit(1)\n\t} else {\n\t\tlog.Println(\"No tag violations found\")\n\t\tos.Exit(0)\n\t}\n}\n\n"
  },
  {
    "path": "internal/output/sarif.go",
    "content": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype SARIFFormatter struct{}\n\ntype sarifOutput struct {\n\tSchema  string     `json:\"$schema\"`\n\tVersion string     `json:\"version\"`\n\tRuns    []sarifRun `json:\"runs\"`\n}\n\ntype sarifRun struct {\n\tTool struct {\n\t\tDriver struct {\n\t\t\tName           string `json:\"name\"`\n\t\t\tInformationURI string `json:\"informationUri\"`\n\t\t} `json:\"driver\"`\n\t} `json:\"tool\"`\n\tResults []sarifResult `json:\"results\"`\n}\n\ntype sarifResult struct {\n\tRuleID  string `json:\"ruleId\"`\n\tKind    string `json:\"kind\"`\n\tLevel   string `json:\"level,omitempty\"`\n\tMessage struct {\n\t\tText string `json:\"text\"`\n\t} `json:\"message\"`\n\tLocations []sarifLocation `json:\"locations\"`\n}\n\ntype sarifLocation struct {\n\tPhysicalLocation struct {\n\t\tArtifactLocation struct {\n\t\t\tURI string `json:\"uri\"`\n\t\t} `json:\"artifactLocation\"`\n\t\tRegion struct {\n\t\t\tStartLine int `json:\"startLine\"`\n\t\t} `json:\"region\"`\n\t} `json:\"physicalLocation\"`\n}\n\n// Format formats violations as SARIF v2.1.0\nfunc (f *SARIFFormatter) Format(violations []shared.Violation) ([]byte, error) {\n\tvar results []sarifResult\n\n\tfor _, v := range violations {\n\t\tr := sarifResult{RuleID: \"missing-tags\"}\n\t\tr.Message.Text = fmt.Sprintf(\"%s %q is missing tags: %s\",\n\t\t\tv.ResourceType, v.ResourceName, strings.Join(v.MissingTags, \", \"))\n\n\t\tif v.Skip {\n\t\t\tr.Kind = \"notApplicable\"\n\t\t} else {\n\t\t\tr.Kind = \"fail\"\n\t\t\tr.Level = \"error\"\n\t\t}\n\n\t\tloc := sarifLocation{}\n\t\tloc.PhysicalLocation.ArtifactLocation.URI = v.FilePath\n\t\tloc.PhysicalLocation.Region.StartLine = v.Line\n\t\tr.Locations = []sarifLocation{loc}\n\n\t\tresults = append(results, r)\n\t}\n\n\trun := sarifRun{Results: results}\n\trun.Tool.Driver.Name = \"tag-nag\"\n\trun.Tool.Driver.InformationURI = \"https://github.com/jakebark/tag-nag\"\n\n\toutput := sarifOutput{\n\t\tSchema:  \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json\",\n\t\tVersion: \"2.1.0\",\n\t\tRuns:    []sarifRun{run},\n\t}\n\n\treturn json.MarshalIndent(output, \"\", \"  \")\n}\n"
  },
  {
    "path": "internal/output/sarif_test.go",
    "content": "package output\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestSARIFFormatter_Format(t *testing.T) {\n\ttestCases := []struct {\n\t\tname         string\n\t\tviolations   []shared.Violation\n\t\twantResults  int\n\t\twantFailures int\n\t}{\n\t\t{\n\t\t\tname:         \"empty violations\",\n\t\t\tviolations:   []shared.Violation{},\n\t\t\twantResults:  0,\n\t\t\twantFailures: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"single violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{\n\t\t\t\t\tResourceType: \"aws_s3_bucket\",\n\t\t\t\t\tResourceName: \"test\",\n\t\t\t\t\tMissingTags:  []string{\"Owner\"},\n\t\t\t\t\tFilePath:     \"main.tf\",\n\t\t\t\t\tLine:         10,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantResults:  1,\n\t\t\twantFailures: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}, FilePath: \"main.tf\", Line: 1},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", MissingTags: []string{\"Env\"}, FilePath: \"main.tf\", Line: 20},\n\t\t\t},\n\t\t\twantResults:  2,\n\t\t\twantFailures: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"skipped violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test\", Skip: true, FilePath: \"main.tf\", Line: 1},\n\t\t\t},\n\t\t\twantResults:  1,\n\t\t\twantFailures: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}, FilePath: \"main.tf\", Line: 1},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", Skip: true, FilePath: \"main.tf\", Line: 10},\n\t\t\t},\n\t\t\twantResults:  2,\n\t\t\twantFailures: 1,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tformatter := &SARIFFormatter{}\n\t\t\toutput, err := formatter.Format(tc.violations)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Format() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar parsed sarifOutput\n\t\t\tif err := json.Unmarshal(output, &parsed); err != nil {\n\t\t\t\tt.Errorf(\"Output is not valid JSON: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif parsed.Version != \"2.1.0\" {\n\t\t\t\tt.Errorf(\"Version = %q; want %q\", parsed.Version, \"2.1.0\")\n\t\t\t}\n\n\t\t\tif len(parsed.Runs) != 1 {\n\t\t\t\tt.Fatalf(\"Runs count = %d; want 1\", len(parsed.Runs))\n\t\t\t}\n\n\t\t\tresults := parsed.Runs[0].Results\n\t\t\tif len(results) != tc.wantResults {\n\t\t\t\tt.Errorf(\"Results count = %d; want %d\", len(results), tc.wantResults)\n\t\t\t}\n\n\t\t\tfailures := 0\n\t\t\tfor _, r := range results {\n\t\t\t\tif r.Kind == \"fail\" {\n\t\t\t\t\tfailures++\n\t\t\t\t}\n\t\t\t}\n\t\t\tif failures != tc.wantFailures {\n\t\t\t\tt.Errorf(\"Failure count = %d; want %d\", failures, tc.wantFailures)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/output/text.go",
    "content": "package output\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype TextFormatter struct{}\n\n// Format formats violations as human-readable text\nfunc (f *TextFormatter) Format(violations []shared.Violation) ([]byte, error) {\n\tvar output strings.Builder\n\n\tfileGroups := make(map[string][]shared.Violation)\n\tfor _, v := range violations {\n\t\tfileGroups[v.FilePath] = append(fileGroups[v.FilePath], v)\n\t}\n\n\tfor filePath, fileViolations := range fileGroups {\n\t\toutput.WriteString(fmt.Sprintf(\"\\nViolation(s) in %s\\n\", filePath))\n\n\t\tfor _, v := range fileViolations {\n\t\t\tif v.Skip {\n\t\t\t\toutput.WriteString(fmt.Sprintf(\"  %d: %s \\\"%s\\\" skipped\\n\",\n\t\t\t\t\tv.Line, v.ResourceType, v.ResourceName))\n\t\t\t} else {\n\t\t\t\toutput.WriteString(fmt.Sprintf(\"  %d: %s \\\"%s\\\" 🏷️  Missing tags: %s\\n\",\n\t\t\t\t\tv.Line, v.ResourceType, v.ResourceName, strings.Join(v.MissingTags, \", \")))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn []byte(output.String()), nil\n}\n"
  },
  {
    "path": "internal/output/text_test.go",
    "content": "package output\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\nfunc TestTextFormatter_Format(t *testing.T) {\n\ttestCases := []struct {\n\t\tname           string\n\t\tviolations     []shared.Violation\n\t\twantContains   []string\n\t\twantNotContains []string\n\t}{\n\t\t{\n\t\t\tname:       \"empty violations\",\n\t\t\tviolations: []shared.Violation{},\n\t\t\twantContains: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"single violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{\n\t\t\t\t\tResourceType: \"aws_s3_bucket\",\n\t\t\t\t\tResourceName: \"test\",\n\t\t\t\t\tMissingTags:  []string{\"Owner\"},\n\t\t\t\t\tFilePath:     \"main.tf\",\n\t\t\t\t\tLine:         10,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantContains: []string{\n\t\t\t\t\"Violation(s) in main.tf\",\n\t\t\t\t\"10: aws_s3_bucket \\\"test\\\"\",\n\t\t\t\t\"Missing tags: Owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple violations same file\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}, FilePath: \"main.tf\", Line: 5},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", MissingTags: []string{\"Env\"}, FilePath: \"main.tf\", Line: 15},\n\t\t\t},\n\t\t\twantContains: []string{\n\t\t\t\t\"Violation(s) in main.tf\",\n\t\t\t\t\"5: aws_s3_bucket \\\"test1\\\"\",\n\t\t\t\t\"15: aws_instance \\\"test2\\\"\",\n\t\t\t\t\"Missing tags: Owner\",\n\t\t\t\t\"Missing tags: Env\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"skipped violation\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test\", FilePath: \"main.tf\", Line: 10, Skip: true},\n\t\t\t},\n\t\t\twantContains: []string{\n\t\t\t\t\"Violation(s) in main.tf\",\n\t\t\t\t\"10: aws_s3_bucket \\\"test\\\" skipped\",\n\t\t\t},\n\t\t\twantNotContains: []string{\n\t\t\t\t\"Missing tags:\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed violations\",\n\t\t\tviolations: []shared.Violation{\n\t\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"test1\", MissingTags: []string{\"Owner\"}, FilePath: \"main.tf\", Line: 5},\n\t\t\t\t{ResourceType: \"aws_instance\", ResourceName: \"test2\", FilePath: \"main.tf\", Line: 15, Skip: true},\n\t\t\t},\n\t\t\twantContains: []string{\n\t\t\t\t\"Violation(s) in main.tf\",\n\t\t\t\t\"5: aws_s3_bucket \\\"test1\\\"\",\n\t\t\t\t\"Missing tags: Owner\",\n\t\t\t\t\"15: aws_instance \\\"test2\\\" skipped\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tformatter := &TextFormatter{}\n\t\t\toutput, err := formatter.Format(tc.violations)\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Format() error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\toutputStr := string(output)\n\n\t\t\tfor _, want := range tc.wantContains {\n\t\t\t\tif !strings.Contains(outputStr, want) {\n\t\t\t\t\tt.Errorf(\"Output missing %q\", want)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor _, notWant := range tc.wantNotContains {\n\t\t\t\tif strings.Contains(outputStr, notWant) {\n\t\t\t\t\tt.Errorf(\"Output should not contain %q\", notWant)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}"
  },
  {
    "path": "internal/shared/helpers.go",
    "content": "package shared\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// FilterMissingTags checks effectiveTags against requiredTags\nfunc FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseInsensitive bool) []string {\n\tvar missingTags []string\n\n\tfor requiredKey, allowedValues := range requiredTags {\n\t\teffectiveValues, keyFound := matchTagKey(requiredKey, effectiveTags, caseInsensitive)\n\n\t\t// construct violation message\n\t\tviolationMessage := requiredKey\n\t\tif len(allowedValues) > 0 {\n\t\t\tviolationMessage = fmt.Sprintf(\"%s[%s]\", requiredKey, strings.Join(allowedValues, \",\"))\n\t\t}\n\t\tif !keyFound {\n\t\t\tmissingTags = append(missingTags, violationMessage)\n\t\t\tcontinue\n\t\t}\n\n\t\t// if there are tag values required, check them\n\t\tif len(allowedValues) > 0 {\n\t\t\tif !matchTagValue(allowedValues, effectiveValues, caseInsensitive) {\n\t\t\t\tmissingTags = append(missingTags, violationMessage)\n\t\t\t}\n\t\t}\n\t}\n\n\tsort.Strings(missingTags) //\n\treturn missingTags\n}\n\n// matchTagKey checks required tag key against effective tags\nfunc matchTagKey(requiredKey string, effectiveTags TagMap, caseInsensitive bool) (values []string, found bool) {\n\tfor effectiveKey, effectiveValues := range effectiveTags {\n\t\tif CompareCase(effectiveKey, requiredKey, caseInsensitive) {\n\t\t\treturn effectiveValues, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// matchTagValue checks required tag values (if present) against effective tags\nfunc matchTagValue(allowedValues []string, effectiveValues []string, caseInsensitive bool) bool {\n\tif len(allowedValues) == 0 { // if no tag alues are required, return match\n\t\treturn true\n\t}\n\tif len(effectiveValues) == 0 && len(allowedValues) > 0 {\n\t\treturn false\n\t}\n\n\tfor _, allowed := range allowedValues {\n\t\tfor _, effectiveValue := range effectiveValues {\n\t\t\tif CompareCase(effectiveValue, allowed, caseInsensitive) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// NormalizeCase lowers the case if caseInsensitive is true\nfunc NormalizeCase(input string, caseInsensitive bool) string {\n\tif caseInsensitive {\n\t\treturn strings.ToLower(input)\n\t}\n\treturn input\n}\n\n// CompareCase compares case sensitivity where appropriate\nfunc CompareCase(first, second string, caseInsensitive bool) bool {\n\tif caseInsensitive {\n\t\treturn strings.EqualFold(first, second)\n\t}\n\treturn first == second\n}\n"
  },
  {
    "path": "internal/shared/helpers_test.go",
    "content": "package shared\n\nimport (\n\t\"reflect\"\n\t\"sort\"\n\t\"testing\"\n)\n\nfunc TestFilterMissingTags(t *testing.T) {\n\ttestCases := []struct {\n\t\tname            string\n\t\trequiredTags    TagMap\n\t\teffectiveTags   TagMap\n\t\tcaseInsensitive bool\n\t\texpectedMissing []string\n\t}{\n\t\t{\n\t\t\tname:            \"tags present\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\", \"Dev\"}},\n\t\t\teffectiveTags:   TagMap{\"Owner\": {\"a\"}, \"Env\": {\"Prod\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"missing key\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"Env\": {\"Prod\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Owner\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"wrong value\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"Owner\": {\"a\"}, \"Env\": {\"Dev\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Env[Prod]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"missing key and value\",\n\t\t\trequiredTags:    TagMap{\"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"Owner\": {\"a\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Env[Prod]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"tags present, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\", \"Dev\"}},\n\t\t\teffectiveTags:   TagMap{\"owner\": {\"a\"}, \"env\": {\"prod\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"missing key, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"env\": {\"Prod\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: []string{\"Owner\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"wrong value, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"owner\": {\"a\"}, \"env\": {\"Dev\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: []string{\"Env[Prod]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"missing key and value, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"owner\": {\"a\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: []string{\"Env[Prod]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"no required tags\",\n\t\t\trequiredTags:    TagMap{},\n\t\t\teffectiveTags:   TagMap{\"Owner\": {\"a\"}, \"Env\": {\"Dev\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"no tags\",\n\t\t\trequiredTags:    TagMap{\"Owner\": {}, \"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Owner\", \"Env[Prod]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values required, one present\",\n\t\t\trequiredTags:    TagMap{\"Region\": {\"us-east-1\", \"us-west-2\"}},\n\t\t\teffectiveTags:   TagMap{\"Region\": {\"us-west-2\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values required, none present\",\n\t\t\trequiredTags:    TagMap{\"Region\": {\"us-east-1\", \"us-west-2\"}},\n\t\t\teffectiveTags:   TagMap{\"Region\": {\"eu-central-1\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Region[us-east-1,us-west-2]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values required, one present, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Env\": {\"Prod\", \"Dev\"}},\n\t\t\teffectiveTags:   TagMap{\"Env\": {\"prod\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values required, none present, case insensitive\",\n\t\t\trequiredTags:    TagMap{\"Region\": {\"us-east-1\", \"us-west-2\"}},\n\t\t\teffectiveTags:   TagMap{\"region\": {\"eu-central-1\"}},\n\t\t\tcaseInsensitive: true,\n\t\t\texpectedMissing: []string{\"Region[us-east-1,us-west-2]\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values required, multiple values present\",\n\t\t\trequiredTags:    TagMap{\"Env\": {\"Prod\", \"Dev\"}},\n\t\t\teffectiveTags:   TagMap{\"Env\": {\"Prod\", \"Stage\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: nil,\n\t\t},\n\t\t{\n\t\t\tname:            \"multiple values present, none match\",\n\t\t\trequiredTags:    TagMap{\"Env\": {\"Prod\"}},\n\t\t\teffectiveTags:   TagMap{\"Env\": {\"Dev\", \"Stage\"}},\n\t\t\tcaseInsensitive: false,\n\t\t\texpectedMissing: []string{\"Env[Prod]\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := FilterMissingTags(tc.requiredTags, tc.effectiveTags, tc.caseInsensitive)\n\n\t\t\tif actual != nil && tc.expectedMissing != nil {\n\t\t\t\tsort.Strings(actual)\n\t\t\t\tsort.Strings(tc.expectedMissing)\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(actual, tc.expectedMissing) {\n\t\t\t\tt.Errorf(\"FilterMissingTags() = %#v; want %#v\", actual, tc.expectedMissing)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNormalizeCase(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tinput           string\n\t\tcaseInsensitive bool\n\t\texpected        string\n\t}{\n\t\t{\n\t\t\tname:            \"preserve case when false\",\n\t\t\tinput:           \"Owner\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        \"Owner\",\n\t\t},\n\t\t{\n\t\t\tname:            \"lowercase when true\",\n\t\t\tinput:           \"Owner\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"owner\",\n\t\t},\n\t\t{\n\t\t\tname:            \"already lowercase\",\n\t\t\tinput:           \"owner\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"owner\",\n\t\t},\n\t\t{\n\t\t\tname:            \"mixed case\",\n\t\t\tinput:           \"EnViRoNmEnT\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"environment\",\n\t\t},\n\t\t{\n\t\t\tname:            \"all uppercase\",\n\t\t\tinput:           \"ENVIRONMENT\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"environment\",\n\t\t},\n\t\t{\n\t\t\tname:            \"empty string case sensitive\",\n\t\t\tinput:           \"\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:            \"empty string case insensitive\",\n\t\t\tinput:           \"\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:            \"special characters preserved\",\n\t\t\tinput:           \"Tag-Name_123\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"tag-name_123\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := NormalizeCase(tc.input, tc.caseInsensitive)\n\t\t\tif actual != tc.expected {\n\t\t\t\tt.Errorf(\"NormalizeCase(%q, %v) = %q; want %q\", tc.input, tc.caseInsensitive, actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCompareCase(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tfirst           string\n\t\tsecond          string\n\t\tcaseInsensitive bool\n\t\texpected        bool\n\t}{\n\t\t{\n\t\t\tname:            \"exact match case sensitive\",\n\t\t\tfirst:           \"Owner\",\n\t\t\tsecond:          \"Owner\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"case mismatch case sensitive\",\n\t\t\tfirst:           \"Owner\",\n\t\t\tsecond:          \"owner\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        false,\n\t\t},\n\t\t{\n\t\t\tname:            \"case mismatch case insensitive\",\n\t\t\tfirst:           \"Owner\",\n\t\t\tsecond:          \"owner\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"different strings case sensitive\",\n\t\t\tfirst:           \"Owner\",\n\t\t\tsecond:          \"Environment\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        false,\n\t\t},\n\t\t{\n\t\t\tname:            \"different strings case insensitive\",\n\t\t\tfirst:           \"Owner\",\n\t\t\tsecond:          \"Environment\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        false,\n\t\t},\n\t\t{\n\t\t\tname:            \"mixed case match case insensitive\",\n\t\t\tfirst:           \"EnViRoNmEnT\",\n\t\t\tsecond:          \"environment\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"both uppercase case insensitive\",\n\t\t\tfirst:           \"ENVIRONMENT\",\n\t\t\tsecond:          \"ENVIRONMENT\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"empty strings\",\n\t\t\tfirst:           \"\",\n\t\t\tsecond:          \"\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"empty vs non-empty\",\n\t\t\tfirst:           \"\",\n\t\t\tsecond:          \"Owner\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        false,\n\t\t},\n\t\t{\n\t\t\tname:            \"special characters case insensitive\",\n\t\t\tfirst:           \"Tag-Name_123\",\n\t\t\tsecond:          \"tag-name_123\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        true,\n\t\t},\n\t\t{\n\t\t\tname:            \"unicode case insensitive\",\n\t\t\tfirst:           \"Café\",\n\t\t\tsecond:          \"café\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        true,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := CompareCase(tc.first, tc.second, tc.caseInsensitive)\n\t\t\tif actual != tc.expected {\n\t\t\t\tt.Errorf(\"CompareCase(%q, %q, %v) = %v; want %v\", tc.first, tc.second, tc.caseInsensitive, actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/shared/types.go",
    "content": "package shared\n\ntype TagMap map[string][]string\n\ntype Violation struct {\n\tResourceType string   `json:\"resource_type\"`\n\tResourceName string   `json:\"resource_name\"`\n\tLine         int      `json:\"line\"`\n\tMissingTags  []string `json:\"missing_tags\"`\n\tSkip         bool     `json:\"skip\"`\n\tFilePath     string   `json:\"file_path\"`\n}\n\ntype OutputFormat string\n\nconst (\n\tOutputFormatText     OutputFormat = \"text\"\n\tOutputFormatJSON     OutputFormat = \"json\"\n\tOutputFormatJUnitXML OutputFormat = \"junit-xml\"\n\tOutputFormatSARIF    OutputFormat = \"sarif\"\n)\n"
  },
  {
    "path": "internal/terraform/default_tags.go",
    "content": "package terraform\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclparse\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n)\n\n// processDefaultTags identifies the default tags\nfunc processDefaultTags(tfFiles []tfFile, tfContext *TerraformContext, caseInsensitive bool) DefaultTags {\n\tdefaultTags := DefaultTags{\n\t\tLiteralTags: make(map[string]shared.TagMap),\n\t}\n\n\tparser := hclparse.NewParser()\n\n\tfor _, tf := range tfFiles {\n\t\tfile, diags := parser.ParseHCLFile(tf.path)\n\t\tif diags.HasErrors() || file == nil {\n\t\t\tlog.Printf(\"Error parsing %s during default tag scan: %v\\n\", tf.path, diags)\n\t\t\tcontinue\n\t\t}\n\n\t\tsyntaxBody, ok := file.Body.(*hclsyntax.Body)\n\t\tif !ok {\n\t\t\tlog.Printf(\"Failed to get syntax body for %s\\n\", tf.path)\n\t\t\tcontinue\n\t\t}\n\n\t\tprocessProviders(syntaxBody, &defaultTags, tfContext, caseInsensitive)\n\t}\n\n\treturn defaultTags\n}\n\n// processProviders extracts any default_tags from providers\nfunc processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool) {\n\tfor _, block := range body.Blocks {\n\t\tif block.Type == \"provider\" && len(block.Labels) > 0 {\n\t\t\tproviderID := getProviderID(block, caseInsensitive)   // handle ID\n\t\t\ttags := getDefaultTags(block, tfContext, caseInsensitive) // handle tags\n\n\t\t\tif len(tags) > 0 {\n\t\t\t\tvar keys []string\n\t\t\t\tfor key := range tags {\n\t\t\t\t\tkeys = append(keys, key) // remove bool element of tag map\n\t\t\t\t}\n\t\t\t\tsort.Strings(keys)\n\t\t\t\tfmt.Printf(\"Found Terraform default tags for provider %s: [%v]\\n\", providerID, strings.Join(keys, \", \"))\n\t\t\t\tdefaultTags.LiteralTags[providerID] = tags\n\n\t\t\t}\n\t\t}\n\t}\n}\n\n// getProviderID  returns  the provider identifier (aws or alias)\nfunc getProviderID(block *hclsyntax.Block, caseInsensitive bool) string {\n\tproviderName := block.Labels[0]\n\tvar alias string\n\n\t// check for alias presence\n\tif attr, ok := block.Body.Attributes[\"alias\"]; ok {\n\t\tval, diags := attr.Expr.Value(nil)\n\t\tif !diags.HasErrors() {\n\t\t\talias = val.AsString()\n\t\t}\n\t}\n\treturn normalizeProviderID(providerName, alias, caseInsensitive)\n}\n\n// normalize ProviderID combines the provider name and alias (\"aws.west\"), aligning with resource provider naming\nfunc normalizeProviderID(providerName, alias string, caseInsensitive bool) string {\n\tproviderID := providerName\n\tif alias != \"\" {\n\t\tproviderID += \".\" + alias\n\t}\n\n\tproviderID = shared.NormalizeCase(providerID, caseInsensitive)\n\n\treturn providerID\n}\n\n// getDefaultTags returns the default_tags on a provider block.\nfunc getDefaultTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap { // Add tfContext param\n\tfor _, subBlock := range block.Body.Blocks {\n\t\tif subBlock.Type == \"default_tags\" {\n\t\t\tif tagsAttr, exists := subBlock.Body.Attributes[\"tags\"]; exists {\n\t\t\t\ttagsVal, diags := tagsAttr.Expr.Value(tfContext.EvalContext)\n\t\t\t\tif diags.HasErrors() {\n\t\t\t\t\tlog.Printf(\"Error evaluating default_tags expression for provider %v: %v\", block.Labels, diags)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tif !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {\n\t\t\t\t\tlog.Printf(\"Warning: Evaluated default_tags for provider %v is not an object/map type, but %s. Skipping.\", block.Labels, tagsVal.Type().FriendlyName())\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif tagsVal.IsNull() {\n\t\t\t\t\tlog.Printf(\"Warning: Evaluated default_tags for provider %v is null. Skipping.\", block.Labels)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tevalTags := make(shared.TagMap)\n\t\t\t\tfor key, val := range tagsVal.AsValueMap() {\n\t\t\t\t\tvar valStr string\n\t\t\t\t\tif val.IsNull() {\n\t\t\t\t\t\tvalStr = \"\"\n\t\t\t\t\t} else if val.Type() == cty.String {\n\t\t\t\t\t\tvalStr = val.AsString()\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstrResult, err := convertCtyValueToString(val)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tlog.Printf(\"Warning: Could not convert default tag value for key %q to string: %v. Using empty string.\", key, err)\n\t\t\t\t\t\t\tvalStr = \"\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tvalStr = strResult\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\teffectiveKey := shared.NormalizeCase(key, caseInsensitive)\n\t\t\t\t\tevalTags[effectiveKey] = []string{valStr}\n\t\t\t\t}\n\t\t\t\treturn evalTags\n\t\t\t}\n\t\t}\n\t}\n\treturn nil // No default_tags block found\n}\n"
  },
  {
    "path": "internal/terraform/default_tags_test.go",
    "content": "package terraform\n\nimport (\n\t\"testing\"\n)\n\n// Test for normalizeProviderID\nfunc TestNormalizeProviderID(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tproviderName    string\n\t\talias           string\n\t\tcaseInsensitive bool\n\t\texpected        string\n\t}{\n\t\t{\n\t\t\tname:         \"default\",\n\t\t\tproviderName: \"aws\",\n\t\t\talias:        \"\",\n\t\t\texpected:     \"aws\",\n\t\t},\n\t\t{\n\t\t\tname:         \"alias\",\n\t\t\tproviderName: \"aws\",\n\t\t\talias:        \"west\",\n\t\t\texpected:     \"aws.west\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := normalizeProviderID(tc.providerName, tc.alias, tc.caseInsensitive); got != tc.expected {\n\t\t\t\tt.Errorf(\"normalizeProviderID() = %v, expected %v\", got, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/terraform/helpers.go",
    "content": "package terraform\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n\tctyjson \"github.com/zclconf/go-cty/cty/json\"\n)\n\n// traversalToString converts a hcl hierachical/traversal string to a literal string\nfunc traversalToString(expr hcl.Expression, caseInsensitive bool) string {\n\tif ste, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok {\n\t\ttokens := []string{}\n\t\tfor _, step := range ste.Traversal {\n\t\t\tswitch t := step.(type) {\n\t\t\tcase hcl.TraverseRoot:\n\t\t\t\ttokens = append(tokens, t.Name)\n\t\t\tcase hcl.TraverseAttr:\n\t\t\t\ttokens = append(tokens, t.Name)\n\t\t\t}\n\t\t}\n\t\tresult := shared.NormalizeCase(strings.Join(tokens, \".\"), caseInsensitive)\n\t\treturn result\n\t}\n\t// fallback - attempt to evaluate the expression as a literal value\n\tif v, diags := expr.Value(nil); !diags.HasErrors() {\n\t\tif v.Type().Equals(cty.String) {\n\t\t\ts := shared.NormalizeCase(v.AsString(), caseInsensitive)\n\t\t\treturn s\n\t\t} else {\n\t\t\treturn fmt.Sprintf(\"%v\", v)\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// mergeTags combines multiple tag maps\nfunc mergeTags(tagMaps ...shared.TagMap) shared.TagMap {\n\tmerged := make(shared.TagMap)\n\tfor _, m := range tagMaps {\n\t\tfor k, v := range m {\n\t\t\tmerged[k] = v\n\t\t}\n\t}\n\treturn merged\n}\n\n// SkipResource determines if a resource block should be skipped\nfunc SkipResource(block *hclsyntax.Block, lines []string) bool {\n\tindex := block.DefRange().Start.Line\n\tif index < len(lines) {\n\t\tif strings.Contains(lines[index], config.TagNagIgnore) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc convertCtyValueToString(val cty.Value) (string, error) {\n\tif !val.IsKnown() {\n\t\treturn \"\", fmt.Errorf(\"value is unknown\")\n\t}\n\tif val.IsNull() {\n\t\treturn \"\", nil\n\t}\n\n\tty := val.Type()\n\tswitch {\n\tcase ty == cty.String:\n\t\treturn val.AsString(), nil\n\tcase ty == cty.Number:\n\t\tbf := val.AsBigFloat()\n\t\treturn bf.Text('f', -1), nil\n\tcase ty == cty.Bool:\n\t\treturn fmt.Sprintf(\"%t\", val.True()), nil\n\tcase ty.IsListType() || ty.IsTupleType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType():\n\n\t\tsimpleJSON, err := ctyjson.SimpleJSONValue{Value: val}.MarshalJSON()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to marshal complex type to json: %w\", err)\n\t\t}\n\t\tstrJSON := string(simpleJSON)\n\t\tif len(strJSON) >= 2 && strJSON[0] == '\"' && strJSON[len(strJSON)-1] == '\"' {\n\t\t\tvar unquotedStr string\n\t\t\tif err := json.Unmarshal(simpleJSON, &unquotedStr); err == nil {\n\t\t\t\treturn unquotedStr, nil\n\t\t\t}\n\t\t}\n\t\treturn strJSON, nil\n\tdefault:\n\t\treturn fmt.Sprintf(\"%v\", val), nil // Best effort\n\t}\n}\n\n// loadTaggableResources calls the Terraform JSON schema and returns a set of all resources that are taggable\nfunc loadTaggableResources(providerAddr string) map[string]bool {\n\tout, err := exec.Command(\n\t\t\"terraform\", \"providers\", \"schema\", \"-json\",\n\t).Output()\n\tif err != nil {\n\t\t// log.Printf(\"Failed to load AWS terraform provider schema: %v\", err)\n\t\treturn nil\n\t}\n\n\t// unmarshall what we need\n\tvar s struct {\n\t\tProviderSchemas map[string]struct {\n\t\t\tResourceSchemas map[string]struct {\n\t\t\t\tBlock struct {\n\t\t\t\t\tAttributes map[string]json.RawMessage `json:\"attributes\"`\n\t\t\t\t} `json:\"block\"`\n\t\t\t} `json:\"resource_schemas\"`\n\t\t} `json:\"provider_schemas\"`\n\t}\n\tif err := json.Unmarshal(out, &s); err != nil {\n\t\tlog.Fatalf(\"failed to parse schema JSON: %v\", err)\n\t\treturn nil\n\t}\n\n\ttaggable := make(map[string]bool)\n\tif ps, ok := s.ProviderSchemas[providerAddr]; ok {\n\t\tfor resType, schema := range ps.ResourceSchemas {\n\t\t\tif _, has := schema.Block.Attributes[\"tags\"]; has {\n\t\t\t\ttaggable[resType] = true\n\t\t\t} else { //\n\t\t\t\ttaggable[resType] = false\n\t\t\t}\n\t\t}\n\t} else {\n\t\treturn nil\n\t}\n\n\treturn taggable\n}\n"
  },
  {
    "path": "internal/terraform/helpers_test.go",
    "content": "package terraform\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n)\n\nfunc TestTraversalToString(t *testing.T) {\n\ttestCases := []struct {\n\t\tname            string\n\t\thclInput        string\n\t\tcaseInsensitive bool\n\t\texpected        string\n\t}{\n\t\t{\n\t\t\tname:            \"literal\",\n\t\t\thclInput:        \"Owner\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        \"Owner\",\n\t\t},\n\t\t{\n\t\t\tname:            \"literal, case insensitive\",\n\t\t\thclInput:        \"Owner\",\n\t\t\tcaseInsensitive: true,\n\t\t\texpected:        \"owner\",\n\t\t},\n\t\t{\n\t\t\tname:            \"traversal\",\n\t\t\thclInput:        \"local.network.subnets[0].id\",\n\t\t\tcaseInsensitive: false,\n\t\t\texpected:        \"local.network.subnets.id\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Parse the HCL expression string\n\t\t\texpr, diags := hclsyntax.ParseExpression([]byte(tc.hclInput), tc.name+\".tf\", hcl.Pos{Line: 1, Column: 1})\n\t\t\tif diags.HasErrors() {\n\t\t\t\tt.Fatalf(\"Failed to parse expression %q: %v\", tc.hclInput, diags)\n\t\t\t}\n\n\t\t\tactual := traversalToString(expr, tc.caseInsensitive)\n\t\t\tif actual != tc.expected {\n\t\t\t\tt.Errorf(\"traversalToString(%q, %v) = %q; want %q\", tc.hclInput, tc.caseInsensitive, actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMergeTags(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tinputs   []shared.TagMap\n\t\texpected shared.TagMap\n\t}{\n\t\t{\n\t\t\tname:     \"empty\",\n\t\t\tinputs:   []shared.TagMap{},\n\t\t\texpected: shared.TagMap{},\n\t\t},\n\t\t{\n\t\t\tname: \"key\",\n\t\t\tinputs: []shared.TagMap{\n\t\t\t\t{\"Environment\": {}},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\"Environment\": {}},\n\t\t},\n\t\t{\n\t\t\tname:     \"key and value\",\n\t\t\tinputs:   []shared.TagMap{{\"Environment\": {\"Dev\"}}},\n\t\t\texpected: shared.TagMap{\"Environment\": {\"Dev\"}},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple keys and values\",\n\t\t\tinputs: []shared.TagMap{\n\t\t\t\t{\"Environment\": {\"Dev\"}},\n\t\t\t\t{\"Owner\": {\"Prod\"}},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\"Environment\": {\"Dev\"}, \"Owner\": {\"Prod\"}},\n\t\t},\n\t\t{\n\t\t\tname: \"overlapping values, last wins\",\n\t\t\tinputs: []shared.TagMap{\n\t\t\t\t{\"Environment\": {\"Dev\"}, \"Owner\": {\"jakebark\"}},\n\t\t\t\t{\"Owner\": {\"Jake\"}, \"CostCenter\": {\"C-01\"}},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\"Environment\": {\"Dev\"}, \"Owner\": {\"Jake\"}, \"CostCenter\": {\"C-01\"}},\n\t\t},\n\t\t{\n\t\t\tname: \"overlapping empty value, last wins\",\n\t\t\tinputs: []shared.TagMap{\n\t\t\t\t{\"Environment\": {\"Dev\"}},\n\t\t\t\t{\"Environment\": {}},\n\t\t\t},\n\t\t\texpected: shared.TagMap{\"Environment\": {}},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := mergeTags(tc.inputs...)\n\t\t\tif !reflect.DeepEqual(actual, tc.expected) {\n\t\t\t\tt.Errorf(\"mergeTags() = %v; want %v\", actual, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertCtyValueToString(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tinput   cty.Value\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:  \"string\",\n\t\t\tinput: cty.StringVal(\"hello world\"),\n\t\t\twant:  \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:  \"empty string\",\n\t\t\tinput: cty.StringVal(\"\"),\n\t\t\twant:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:  \"number\",\n\t\t\tinput: cty.NumberIntVal(123),\n\t\t\twant:  \"123\",\n\t\t},\n\t\t{\n\t\t\tname:  \"float\",\n\t\t\tinput: cty.NumberFloatVal(123.45),\n\t\t\twant:  \"123.45\",\n\t\t},\n\t\t{\n\t\t\tname:  \"bool, true\",\n\t\t\tinput: cty.True,\n\t\t\twant:  \"true\",\n\t\t},\n\t\t{\n\t\t\tname:  \"bool, false\",\n\t\t\tinput: cty.False,\n\t\t\twant:  \"false\",\n\t\t},\n\t\t{\n\t\t\tname:  \"null\",\n\t\t\tinput: cty.NullVal(cty.String),\n\t\t\twant:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"unknown value\",\n\t\t\tinput:   cty.UnknownVal(cty.String),\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:  \"list\",\n\t\t\tinput: cty.ListVal([]cty.Value{cty.StringVal(\"a\"), cty.StringVal(\"b\")}),\n\t\t\twant:  `[\"a\",\"b\"]`,\n\t\t},\n\t\t{\n\t\t\tname:  \"map\",\n\t\t\tinput: cty.MapVal(map[string]cty.Value{\"key\": cty.StringVal(\"value\")}),\n\t\t\twant:  `{\"key\":\"value\"}`,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot, err := convertCtyValueToString(tc.input)\n\t\t\tif (err != nil) != tc.wantErr {\n\t\t\t\tt.Fatalf(\"convertCtyValueToString() error = %v, wantErr %v\", err, tc.wantErr)\n\t\t\t}\n\t\t\tif !tc.wantErr && got != tc.want {\n\t\t\t\tt.Errorf(\"convertCtyValueToString() = %v, want %v\", got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/terraform/process.go",
    "content": "package terraform\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclparse\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n\t\"github.com/zclconf/go-cty/cty/function\"\n)\n\ntype tfFile struct {\n\tpath string\n\tinfo os.FileInfo\n}\n\n// ProcessDirectory walks all terraform files in directory\nfunc ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, skip []string) []shared.Violation {\n\thasFiles, err := scan(directoryPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !hasFiles {\n\t\treturn nil\n\t}\n\n\t// log.Println(\"Terraform files found\\n\")\n\tvar allViolations []shared.Violation\n\n\ttaggable := loadTaggableResources(\"registry.terraform.io/hashicorp/aws\")\n\tif taggable == nil {\n\t\t// log.Printf(\"Warning: Failed to load Terraform AWS Provider\\nRun 'terraform init' to fix\\n\")\n\t\t// log.Printf(\"Continuing with limited features ... \\n \")\n\t}\n\n\ttfContext, err := buildTagContext(directoryPath)\n\tif err != nil {\n\t\ttfContext = &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function)}}\n\t}\n\n\t// single directory walk\n\ttfFiles, err := collectFiles(directoryPath, skip)\n\tif err != nil {\n\t\tlog.Printf(\"Error scanning directory %q: %v\\n\", directoryPath, err)\n\t\treturn nil\n\t}\n\n\tif len(tfFiles) == 0 {\n\t\treturn nil\n\t}\n\n\t// extract default tags from all files\n\tdefaultTags := processDefaultTags(tfFiles, tfContext, caseInsensitive)\n\n\t// process resources for tag violations\n\tfor _, tf := range tfFiles {\n\t\tviolations := processFile(tf.path, requiredTags, &defaultTags, tfContext, caseInsensitive, taggable)\n\t\tallViolations = append(allViolations, violations...)\n\t}\n\n\treturn allViolations\n}\n\n// collectFiles identifies all elligible terraform files\nfunc collectFiles(directoryPath string, skip []string) ([]tfFile, error) {\n\tvar tfFiles []tfFile\n\n\terr := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif skipDirectories(path, info, skip) {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\n\t\tif !info.IsDir() && filepath.Ext(path) == \".tf\" {\n\t\t\ttfFiles = append(tfFiles, tfFile{path: path, info: info})\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn tfFiles, err\n}\n\n// skipDir identifies directories to ignore\nfunc skipDirectories(path string, info os.FileInfo, skip []string) bool {\n\t// user-defined skip paths\n\tfor _, skipped := range skip {\n\t\tif strings.HasPrefix(path, skipped) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// default skipped directories eg .git\n\tif info.IsDir() {\n\t\tdirName := info.Name()\n\t\tif slices.Contains(config.SkippedDirs, dirName) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// processFile parses files looking for resources\nfunc processFile(filePath string, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, taggable map[string]bool) []shared.Violation {\n\tdata, err := os.ReadFile(filePath)\n\tif err != nil {\n\t\tlog.Printf(\"Error reading %s: %v\\n\", filePath, err)\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tlines := strings.Split(content, \"\\n\")\n\n\tskipAll := strings.Contains(content, config.TagNagIgnoreAll)\n\n\tparser := hclparse.NewParser()\n\tfile, diagnostics := parser.ParseHCLFile(filePath)\n\n\tif diagnostics.HasErrors() {\n\t\tlog.Printf(\"Error parsing %s: %v\\n\", filePath, diagnostics)\n\t\treturn nil\n\t}\n\n\tsyntaxBody, ok := file.Body.(*hclsyntax.Body)\n\tif !ok {\n\t\tlog.Printf(\"Parsing failed for %s\\n\", filePath)\n\t\treturn nil\n\t}\n\n\tviolations := checkResourcesForTags(syntaxBody, requiredTags, defaultTags, tfContext, caseInsensitive, lines, skipAll, taggable, filePath)\n\treturn violations\n}\n"
  },
  {
    "path": "internal/terraform/references.go",
    "content": "package terraform\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclparse\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/config\"\n\t\"github.com/zclconf/go-cty/cty\"\n\t\"github.com/zclconf/go-cty/cty/function\"\n)\n\nfunc buildTagContext(directoryPath string) (*TerraformContext, error) {\n\tparsedFiles := make(map[string]*hcl.File)\n\tparser := hclparse.NewParser()\n\n\t// first pass, parse files\n\terr := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tdirName := info.Name()\n\t\t\tfor _, skipped := range config.SkippedDirs {\n\t\t\t\tif dirName == skipped {\n\t\t\t\t\treturn filepath.SkipDir\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !info.IsDir() && filepath.Ext(path) == \".tf\" {\n\t\t\tfile, diags := parser.ParseHCLFile(path)\n\t\t\tif diags.HasErrors() {\n\t\t\t\tlog.Printf(\"Error parsing HCL file %s: %v\\n\", path, diags)\n\t\t\t}\n\t\t\tif file != nil {\n\t\t\t\tparsedFiles[path] = file\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error walking directory %s: %w\", directoryPath, err)\n\t}\n\n\tif len(parsedFiles) == 0 {\n\t\tlog.Println(\"No Terraform files (.tf) found to build context.\")\n\t\treturn &TerraformContext{\n\t\t\tEvalContext: &hcl.EvalContext{\n\t\t\t\tVariables: make(map[string]cty.Value),\n\t\t\t\tFunctions: make(map[string]function.Function),\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// second pass, evaluate vars\n\ttfVars := make(map[string]cty.Value)\n\tfor _, file := range parsedFiles {\n\t\tbody, ok := file.Body.(*hclsyntax.Body)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, block := range body.Blocks {\n\t\t\tif block.Type == \"variable\" && len(block.Labels) > 0 {\n\t\t\t\tvarName := block.Labels[0]\n\t\t\t\tif defaultAttr, exists := block.Body.Attributes[\"default\"]; exists {\n\t\t\t\t\tval, diags := defaultAttr.Expr.Value(nil)\n\t\t\t\t\tif diags.HasErrors() {\n\t\t\t\t\t\tlog.Printf(\"Error evaluating default for variable %q: %v\", varName, diags)\n\t\t\t\t\t\tval = cty.NullVal(cty.DynamicPseudoType)\n\t\t\t\t\t}\n\t\t\t\t\ttfVars[varName] = val\n\t\t\t\t} else {\n\t\t\t\t\ttfVars[varName] = cty.NullVal(cty.DynamicPseudoType)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 3rd pass, evaluate locals\n\ttfLocals := make(map[string]cty.Value)\n\tlocalsDefs := make(map[string]hcl.Expression)\n\n\tfor _, file := range parsedFiles {\n\t\tbody, ok := file.Body.(*hclsyntax.Body)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, block := range body.Blocks {\n\t\t\tif block.Type == \"locals\" {\n\t\t\t\tfor name, attr := range block.Body.Attributes {\n\t\t\t\t\tlocalsDefs[name] = attr.Expr\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tevalCtxForLocals := &hcl.EvalContext{\n\t\tVariables: map[string]cty.Value{\"var\": cty.ObjectVal(tfVars)},\n\t\tFunctions: config.StdlibFuncs,\n\t}\n\tevalCtxForLocals.Variables[\"local\"] = cty.NullVal(cty.DynamicPseudoType) // Placeholder for local\n\n\tconst maxLocalPasses = 10\n\tevaluatedCount := 0\n\tfor pass := 0; pass < maxLocalPasses && evaluatedCount < len(localsDefs); pass++ {\n\t\tmadeProgress := false\n\n\t\tevalCtxForLocals.Variables[\"local\"] = cty.ObjectVal(tfLocals)\n\n\t\tfor name, expr := range localsDefs {\n\t\t\tif _, exists := tfLocals[name]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tval, diags := expr.Value(evalCtxForLocals)\n\t\t\tif !diags.HasErrors() {\n\t\t\t\ttfLocals[name] = val\n\t\t\t\tevaluatedCount++\n\t\t\t\tmadeProgress = true\n\t\t\t}\n\n\t\t}\n\t\tif !madeProgress && evaluatedCount < len(localsDefs) {\n\t\t\tlog.Printf(\"Warning: Could not resolve all locals dependencies after %d passes.\", pass+1)\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfinalCtx := &hcl.EvalContext{\n\t\tVariables: map[string]cty.Value{\n\t\t\t\"var\":   cty.ObjectVal(tfVars),\n\t\t\t\"local\": cty.ObjectVal(tfLocals),\n\t\t},\n\t\tFunctions: config.StdlibFuncs,\n\t}\n\n\treturn &TerraformContext{EvalContext: finalCtx}, nil\n}\n"
  },
  {
    "path": "internal/terraform/resources.go",
    "content": "package terraform\n\nimport (\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n)\n\n// checkResourcesForTags inspects resource blocks and returns violations\nfunc checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation {\n\tvar violations []shared.Violation\n\n\tfor _, block := range body.Blocks {\n\t\tif block.Type != \"resource\" || len(block.Labels) < 2 { // skip anything without 2 labels eg \"aws_s3_bucket\" and \"this\"\n\t\t\tcontinue\n\t\t}\n\n\t\tresourceType := block.Labels[0] // aws_s3_bucket\n\t\tresourceName := block.Labels[1] // this\n\n\t\tif !strings.HasPrefix(resourceType, \"aws_\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tisTaggable := true // assume resource is taggable, by default\n\t\tif taggable != nil {\n\t\t\tvar found bool\n\t\t\tisTaggable, found = taggable[resourceType]\n\t\t\tif !found {\n\t\t\t\tisTaggable = true // if not found, assume resource is taggable\n\t\t\t\t// isTaggable = false\n\t\t\t\t// log.Printf(\"Warning: Resource type %s not found in provider schema. Assuming not taggable.\", resourceType) //todo\n\t\t\t}\n\t\t} else {\n\t\t}\n\n\t\tif !isTaggable {\n\t\t\t// log.Printf(\"Skipping non-taggable resource type: %s\", resourceType)\n\t\t\tcontinue\n\t\t}\n\n\t\tproviderID := getResourceProvider(block, caseInsensitive)\n\t\tproviderEvalTags := defaultTags.LiteralTags[providerID]\n\t\tif providerEvalTags == nil {\n\t\t\tproviderEvalTags = make(shared.TagMap)\n\t\t}\n\n\t\tresourceEvalTags := findTags(block, tfContext, caseInsensitive)\n\t\teffectiveTags := mergeTags(providerEvalTags, resourceEvalTags)\n\n\t\tmissingTags := shared.FilterMissingTags(requiredTags, effectiveTags, caseInsensitive)\n\t\tif len(missingTags) > 0 {\n\t\t\tviolation := shared.Violation{\n\t\t\t\tResourceType: resourceType,\n\t\t\t\tResourceName: resourceName,\n\t\t\t\tLine:         block.DefRange().Start.Line,\n\t\t\t\tMissingTags:  missingTags,\n\t\t\t\tFilePath:     filePath,\n\t\t\t}\n\t\t\tif skipAll || SkipResource(block, fileLines) {\n\t\t\t\tviolation.Skip = true\n\t\t\t}\n\t\t\tviolations = append(violations, violation)\n\t\t}\n\t}\n\treturn violations\n}\n\n// getResourceProvider determines the provider for a resource block\nfunc getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) string {\n\tif attr, ok := block.Body.Attributes[\"provider\"]; ok {\n\n\t\t// provider is a literal string (\"aws\")\n\t\tval, diags := attr.Expr.Value(nil)\n\t\tif !diags.HasErrors() {\n\t\t\ts := shared.NormalizeCase(val.AsString(), caseInsensitive)\n\t\t\treturn s\n\t\t}\n\t\t// provider is not a literal string (\"aws.west\")\n\t\ts := traversalToString(attr.Expr, caseInsensitive)\n\t\tif s != \"\" {\n\t\t\treturn s\n\t\t}\n\t}\n\n\t// no explicit provider, return default provider\n\tdefaultProvider := shared.NormalizeCase(\"aws\", caseInsensitive)\n\treturn defaultProvider\n}\n\n// findTags returns tag map from a resource block (with extractTags), if it has tags\nfunc findTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap {\n\tevalTags := make(shared.TagMap)\n\tif attr, exists := block.Body.Attributes[\"tags\"]; exists {\n\n\t\tchildCtx := tfContext.EvalContext.NewChild()\n\t\tif childCtx.Variables == nil {\n\t\t\tchildCtx.Variables = make(map[string]cty.Value)\n\t\t}\n\t\tchildCtx.Variables[\"each\"] = cty.ObjectVal(map[string]cty.Value{\n\t\t\t\"key\":   cty.StringVal(\"\"),\n\t\t\t\"value\": cty.StringVal(\"\"),\n\t\t})\n\t\ttagsVal, diags := attr.Expr.Value(childCtx)\n\n\t\tif diags.HasErrors() {\n\t\t\tlog.Printf(\"Error evaluating tags for resource %s.%s: %v\", block.Labels[0], block.Labels[1], diags)\n\t\t\treturn evalTags\n\t\t}\n\n\t\tif !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {\n\t\t\treturn evalTags\n\t\t}\n\t\tif tagsVal.IsNull() {\n\t\t\treturn evalTags\n\t\t}\n\n\t\tfor key, val := range tagsVal.AsValueMap() {\n\t\t\tvar valStr string\n\t\t\tif val.IsNull() {\n\t\t\t\tvalStr = \"\"\n\t\t\t} else if val.Type() == cty.String {\n\t\t\t\tvalStr = val.AsString()\n\t\t\t} else {\n\t\t\t\tstrResult, err := convertCtyValueToString(val)\n\t\t\t\tif err != nil {\n\t\t\t\t\tvalStr = \"\"\n\t\t\t\t} else {\n\t\t\t\t\tvalStr = strResult\n\t\t\t\t}\n\t\t\t}\n\n\t\t\teffectiveKey := shared.NormalizeCase(key, caseInsensitive)\n\t\t\tevalTags[effectiveKey] = []string{valStr}\n\t\t}\n\t}\n\treturn evalTags\n}\n"
  },
  {
    "path": "internal/terraform/resources_test.go",
    "content": "package terraform\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/hashicorp/hcl/v2/hclparse\"\n\t\"github.com/hashicorp/hcl/v2/hclsyntax\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/zclconf/go-cty/cty\"\n)\n\nfunc TestCheckResourcesForTags_Taggability(t *testing.T) {\n\tparser := hclparse.NewParser()\n\n\ttfCode := `\n\t\tresource \"aws_s3_bucket\" \"taggable_bucket\" {\n\t\t  tags = {\n\t\t\tOwner = \"test-user\" // Missing Environment\n\t\t  }\n\t\t}\n\n\t\tresource \"aws_kms_alias\" \"non_taggable_alias\" {\n\t\t  alias_name       = \"alias/my-key-alias\"\n\t\t  target_key_id = \"some-key-id\"\n\t\t}\n\n\t\tresource \"aws_instance\" \"another_taggable\" {\n\t\t  ami           = \"ami-12345\"\n\t\t  instance_type = \"t2.micro\"\n\t\t  tags = {\n\t\t\tOwner = \"test-user\"\n\t\t\tEnvironment = \"dev\"\n\t\t  }\n\t\t}\n\n\t\tresource \"aws_route53_zone\" \"unknown_in_schema_should_be_checked\" {\n\t\t\tname = \"example.com\"\n\t\t\t# Missing all tags\n\t\t}\n\t`\n\n\tfile, diags := parser.ParseHCL([]byte(tfCode), \"test.tf\")\n\tif diags.HasErrors() {\n\t\tt.Fatalf(\"Failed to parse test HCL: %v\", diags)\n\t}\n\n\tbody, ok := file.Body.(*hclsyntax.Body)\n\tif !ok {\n\t\tt.Fatalf(\"Could not get HCL syntax body\")\n\t}\n\n\trequiredTags := shared.TagMap{\n\t\t\"Owner\":       {},\n\t\t\"Environment\": {},\n\t}\n\tlines := strings.Split(tfCode, \"\\n\")\n\n\tt.Run(\"With Taggability Filter\", func(t *testing.T) {\n\t\ttaggableMap := map[string]bool{\n\t\t\t\"aws_s3_bucket\":    true,\n\t\t\t\"aws_kms_alias\":    false,\n\t\t\t\"aws_instance\":     true,\n\t\t\t\"aws_iam_user\":     false, // Example of another non-taggable not in the HCL\n\t\t\t\"aws_route53_zone\": true,  // Explicitly mark as taggable for test\n\t\t}\n\n\t\tmockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}\n\t\tmockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}\n\n\t\tviolations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, \"test.tf\")\n\n\t\texpectedViolations := []shared.Violation{\n\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"taggable_bucket\", Line: 2, MissingTags: []string{\"Environment\"}, FilePath: \"test.tf\"},\n\t\t\t{ResourceType: \"aws_route53_zone\", ResourceName: \"unknown_in_schema_should_be_checked\", Line: 22, MissingTags: []string{\"Environment\", \"Owner\"}, FilePath: \"test.tf\"}, // Order might vary\n\t\t}\n\n\t\tsortViolations(violations)\n\t\tsortViolations(expectedViolations) // Sort missing tags within each violation for stable comparison\n\n\t\tif diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != \"\" {\n\t\t\tt.Errorf(\"checkResourcesForTags with filter mismatch (-want +got):\\n%s\", diff)\n\t\t}\n\t})\n\n\tt.Run(\"Without Taggability Filter (nil map)\", func(t *testing.T) {\n\t\ttaggableMap := map[string]bool(nil)\n\n\t\tmockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}\n\t\tmockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}\n\n\t\tviolations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, \"test.tf\")\n\n\t\texpectedViolations := []shared.Violation{\n\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"taggable_bucket\", Line: 2, MissingTags: []string{\"Environment\"}, FilePath: \"test.tf\"},\n\t\t\t{ResourceType: \"aws_kms_alias\", ResourceName: \"non_taggable_alias\", Line: 8, MissingTags: []string{\"Environment\", \"Owner\"}, FilePath: \"test.tf\"},\n\t\t\t{ResourceType: \"aws_route53_zone\", ResourceName: \"unknown_in_schema_should_be_checked\", Line: 22, MissingTags: []string{\"Environment\", \"Owner\"}, FilePath: \"test.tf\"},\n\t\t}\n\t\tsortViolations(violations)\n\t\tsortViolations(expectedViolations)\n\n\t\tif diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != \"\" {\n\t\t\tt.Errorf(\"checkResourcesForTags without filter mismatch (-want +got):\\n%s\", diff)\n\t\t}\n\t})\n\n\tt.Run(\"With Taggability Filter - resource type not in map (should assume taggable)\", func(t *testing.T) {\n\t\t// aws_route53_zone is NOT in this specific taggableMap\n\t\ttaggableMap := map[string]bool{\n\t\t\t\"aws_s3_bucket\": true,\n\t\t\t\"aws_kms_alias\": false,\n\t\t\t\"aws_instance\":  true,\n\t\t}\n\n\t\tmockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}\n\t\tmockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}\n\n\t\tviolations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, \"test.tf\")\n\n\t\texpectedViolations := []shared.Violation{\n\t\t\t{ResourceType: \"aws_s3_bucket\", ResourceName: \"taggable_bucket\", Line: 2, MissingTags: []string{\"Environment\"}, FilePath: \"test.tf\"},\n\t\t\t// aws_route53_zone is assumed taggable as it's not in the map with a 'false' entry\n\t\t\t{ResourceType: \"aws_route53_zone\", ResourceName: \"unknown_in_schema_should_be_checked\", Line: 22, MissingTags: []string{\"Environment\", \"Owner\"}, FilePath: \"test.tf\"},\n\t\t}\n\n\t\tsortViolations(violations)\n\t\tsortViolations(expectedViolations)\n\n\t\tif diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != \"\" {\n\t\t\tt.Errorf(\"checkResourcesForTags with incomplete filter mismatch (-want +got):\\n%s\", diff)\n\t\t}\n\t})\n}\n\n// Helper to sort violations for consistent comparison\nfunc sortViolations(violations []shared.Violation) {\n\tfor i := range violations {\n\t\tsort.Strings(violations[i].MissingTags)\n\t}\n\tsort.Slice(violations, func(i, j int) bool {\n\t\tif violations[i].ResourceType != violations[j].ResourceType {\n\t\t\treturn violations[i].ResourceType < violations[j].ResourceType\n\t\t}\n\t\treturn violations[i].ResourceName < violations[j].ResourceName\n\t})\n}\n"
  },
  {
    "path": "internal/terraform/scan.go",
    "content": "package terraform\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"path/filepath\"\n)\n\n// scan looks for tf files\nfunc scan(directoryPath string) (bool, error) {\n\tfound := false\n\ttargetExt := \".tf\"\n\n\twalkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tif !d.IsDir() {\n\t\t\tif filepath.Ext(path) == targetExt {\n\t\t\t\tfound = true\n\t\t\t\treturn fs.ErrNotExist // stop scan immediately\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {\n\t\treturn false, walkErr\n\t}\n\n\treturn found, nil\n}\n"
  },
  {
    "path": "internal/terraform/scan_test.go",
    "content": "package terraform\n\nimport (\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestScanFunction(t *testing.T) {\n\tcreateDummyFile := func(t *testing.T, path string) {\n\t\tt.Helper()\n\t\terr := os.WriteFile(path, []byte(\"dummy content\"), 0644)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create dummy file %s: %v\", path, err)\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tsetupDir func(t *testing.T, dir string)\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname: \"empty dir\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\t// dir is empty\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"no terraform files\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"main.yaml\"))\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"README.md\"))\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"terraform file\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"main.tf\"))\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"other.txt\"))\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple terraform files\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"main.tf\"))\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"variables.tf\"))\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"nested terraform file\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\tsubDir := filepath.Join(dir, \"subdir\")\n\t\t\t\terr := os.Mkdir(subDir, 0755)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create subdir: %v\", err)\n\t\t\t\t}\n\t\t\t\tcreateDummyFile(t, filepath.Join(subDir, \"module.tf\"))\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"root.txt\"))\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"terraform file, nested non-terraform file\",\n\t\t\tsetupDir: func(t *testing.T, dir string) {\n\t\t\t\tsubDir := filepath.Join(dir, \"subdir\")\n\t\t\t\terr := os.Mkdir(subDir, 0755)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to create subdir: %v\", err)\n\t\t\t\t}\n\t\t\t\tcreateDummyFile(t, filepath.Join(dir, \"main.tf\"))\n\t\t\t\tcreateDummyFile(t, filepath.Join(subDir, \"other.yaml\"))\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\ttestPath := tmpDir\n\t\t\tif tc.name == \"Non-existent directory\" {\n\t\t\t\ttestPath = filepath.Join(tmpDir, \"this_dir_should_not_exist\")\n\t\t\t} else {\n\t\t\t\ttc.setupDir(t, tmpDir)\n\t\t\t}\n\t\t\tgotFound, gotErr := scan(testPath)\n\t\t\tif gotErr != nil && !errors.Is(gotErr, fs.ErrNotExist) {\n\t\t\t\tif tc.name != \"Non-existent directory\" {\n\t\t\t\t\tt.Logf(\"scan() returned an unexpected error: %v (test will check 'found' status only)\", gotErr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif gotFound != tc.expected {\n\t\t\t\tt.Errorf(\"scan() found = %v, expected %v\", gotFound, tc.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/terraform/types.go",
    "content": "package terraform\n\nimport (\n\t\"github.com/hashicorp/hcl/v2\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n)\n\ntype DefaultTags struct {\n\tLiteralTags map[string]shared.TagMap\n}\n\n\ntype TerraformContext struct {\n\tEvalContext *hcl.EvalContext\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\n\t\"github.com/jakebark/tag-nag/internal/cloudformation\"\n\t\"github.com/jakebark/tag-nag/internal/inputs\"\n\t\"github.com/jakebark/tag-nag/internal/output\"\n\t\"github.com/jakebark/tag-nag/internal/shared\"\n\t\"github.com/jakebark/tag-nag/internal/terraform\"\n)\n\nfunc main() {\n\tlog.SetFlags(0) // remove timestamp from prints\n\n\tuserInput := inputs.ParseFlags()\n\n\tif userInput.DryRun {\n\t\tlog.Printf(\"\\033[32mDry-run: %s\\033[0m\\n\", userInput.Directory)\n\t} else {\n\t\tlog.Printf(\"\\033[33mScanning: %s\\033[0m\\n\", userInput.Directory)\n\t}\n\n\ttfViolations := terraform.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.Skip)\n\tcfnViolations := cloudformation.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.CfnSpecPath, userInput.Skip)\n\n\tvar allViolations []shared.Violation\n\tallViolations = append(allViolations, tfViolations...)\n\tallViolations = append(allViolations, cfnViolations...)\n\n\toutput.ProcessOutput(allViolations, userInput.OutputFormat, userInput.DryRun)\n}\n"
  },
  {
    "path": "main_test.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"testing\"\n)\n\nconst binaryName = \"tag-nag\"\n\ntype testCases struct {\n\tname             string\n\tfilePathOrDir    string\n\tcliArgs          []string\n\texpectedExitCode int\n\texpectedError    bool\n\texpectedOutput   []string\n}\n\nfunc TestMain(m *testing.M) {\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", binaryName)\n\tif err := cmd.Run(); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Failed to build %s: %v\\n\", binaryName, err)\n\t\tos.Exit(1)\n\t}\n\n\texitVal := m.Run()\n\tos.Remove(binaryName)\n\tos.Exit(exitVal)\n}\n\nfunc runTagNag(t *testing.T, args ...string) (string, error, int) {\n\tt.Helper()\n\tfullArgs := append([]string{\"./\" + binaryName}, args...)\n\tcmd := exec.Command(fullArgs[0], fullArgs[1:]...)\n\tvar outbuf, errbuf bytes.Buffer\n\tcmd.Stdout = &outbuf\n\tcmd.Stderr = &errbuf\n\n\terr := cmd.Run()\n\tstdout := outbuf.String()\n\tstderr := errbuf.String()\n\n\tfullOutput := stdout\n\tif stderr != \"\" {\n\t\tfullOutput += \"\\n\" + stderr\n\t}\n\n\texitCode := 0\n\tif err != nil {\n\t\tif exitError, ok := err.(*exec.ExitError); ok {\n\t\t\texitCode = exitError.ExitCode()\n\t\t} else {\n\t\t\tt.Fatalf(\"Command execution failed with non-exit error: %v, output: %s\", err, fullOutput)\n\t\t}\n\t}\n\treturn fullOutput, err, exitCode\n}\n\nfunc TestInputs(t *testing.T) {\n\ttestCases := []testCases{\n\t\t{\n\t\t\tname:             \"no dir\",\n\t\t\tfilePathOrDir:    \"\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{\"Error: specify a directory or file to scan\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"no tags\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"nonexistent.yml\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{\"specify required tags using --tags or create a .tag-nag.yml config file\"},\n\t\t},\n\n\t\t{\n\t\t\tname:             \"dry run\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\", \"--dry-run\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"Dry-run:\", `aws_s3_bucket \"this\"`, \"Missing tags: Project\"},\n\t\t},\n\t}\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar argsForRun []string\n\t\t\tif tc.filePathOrDir != \"\" {\n\t\t\t\targsForRun = append(argsForRun, tc.filePathOrDir)\n\t\t\t}\n\t\t\targsForRun = append(argsForRun, tc.cliArgs...)\n\n\t\t\toutput, err, exitCode := runTagNag(t, argsForRun...)\n\n\t\t\tif tc.expectedError && err == nil {\n\t\t\t\tt.Errorf(\"Expected an error from command execution, but got none. Output:\\n%s\", output)\n\t\t\t}\n\t\t\tif !tc.expectedError && err != nil {\n\t\t\t\tt.Errorf(\"Expected no error from command execution, but got: %v. Output:\\n%s\", err, output)\n\t\t\t}\n\n\t\t\tif exitCode != tc.expectedExitCode {\n\t\t\t\tt.Errorf(\"Expected exit code %d, got %d. Output:\\n%s\", tc.expectedExitCode, exitCode, output)\n\t\t\t}\n\n\t\t\tfor _, expectedStr := range tc.expectedOutput {\n\t\t\t\tif !strings.Contains(output, expectedStr) {\n\t\t\t\t\tt.Errorf(\"Output missing expected string '%s'. Output:\\n%s\", expectedStr, output)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTerraform(t *testing.T) {\n\ttestCases := []testCases{\n\t\t{\n\t\t\tname:             \"tags\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"missing tags\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: Project\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"no tags\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/no_tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner, Environment\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: Environment, Owner\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"owner,environment\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"lower case\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"owner\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: owner\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"tag values\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[dev,prod]\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"missing tag value\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[test]\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: Environment[test]\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"tag values case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[Dev,Prod]\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"provider\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/provider.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project,Source\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"Found Terraform default tags for provider aws\", \"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"provider\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/provider.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project,Source\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"Found Terraform default tags for provider aws: [Project, Source]\", \"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"provider case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/provider.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"owner,environment,project,source\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"Found Terraform default tags for provider aws: [project, source]\", \"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"provider tag values\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/provider.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[dev,prod],Project,Source[my-repo]\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"Found Terraform default tags for provider aws: [Project, Source]\", \"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"variable tags\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_tags.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"variable value\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner[jakebark],Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"local value\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[dev,prod]\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"variable value case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner[Jakebark],Environment\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"local value case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment[DEV,PROD]\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"interpolation\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project[112233],Source[my-repo]\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"interpolation missing value\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/referenced_values.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project[112233],Source[not-my-repo]\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: Source[not-my-repo]\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"example repo\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/example_repo\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{\"Found Terraform default tags for provider aws: [Environment, Owner, Source]\", `aws_s3_bucket \"baz\"`, \"Found 1 tag violation(s)\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"ignore\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/ignore.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\" skipped`},\n\t\t},\n\t\t{\n\t\t\tname:             \"ignore all\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/ignore_all.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\" skipped`},\n\t\t},\n\t\t{\n\t\t\tname:             \"lower function\",\n\t\t\tfilePathOrDir:    \"testdata/terraform/function.tf\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Environment[dev]\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"skip file\",\n\t\t\tfilePathOrDir:    \"testdata/terraform\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\", \"-s\", \"testdata/terraform/tags.tf\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`aws_s3_bucket \"this\"`, \"Missing tags: Environment, Owner\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"skip directory\",\n\t\t\tfilePathOrDir:    \"testdata\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\", \"-s\", \"testdata/terraform\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`AWS::S3::Bucket \"this\"`, \"Missing tags: Project\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\targsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)\n\t\t\toutput, err, exitCode := runTagNag(t, argsForRun...)\n\n\t\t\tif tc.expectedError && err == nil {\n\t\t\t\tt.Errorf(\"Expected an error from command execution, but got none. Output:\\n%s\", output)\n\t\t\t}\n\t\t\tif !tc.expectedError && err != nil {\n\t\t\t\tt.Errorf(\"Expected no error from command execution, but got: %v. Output:\\n%s\", err, output)\n\t\t\t}\n\n\t\t\tif exitCode != tc.expectedExitCode {\n\t\t\t\tt.Errorf(\"Expected exit code %d, got %d. Output:\\n%s\", tc.expectedExitCode, exitCode, output)\n\t\t\t}\n\n\t\t\tfor _, expectedStr := range tc.expectedOutput {\n\t\t\t\tif !strings.Contains(output, expectedStr) {\n\t\t\t\t\tt.Errorf(\"Output missing expected string '%s'. Output:\\n%s\", expectedStr, output)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudFormation(t *testing.T) {\n\ttestCases := []testCases{\n\t\t{\n\t\t\tname:             \"yml\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.yml\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"yaml\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.yaml\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"json\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.json\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"yaml missing tags\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.yml\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`AWS::S3::Bucket \"this\"`, \"Missing tags: Project\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"json missing tags\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.json\",\n\t\t\tcliArgs:          []string{\"--tags\", \"Owner,Environment,Project\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`AWS::S3::Bucket \"this\"`, \"Missing tags: Project\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"case insensitive\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.yml\",\n\t\t\tcliArgs:          []string{\"--tags\", \"owner,environment\", \"-c\"},\n\t\t\texpectedExitCode: 0,\n\t\t\texpectedError:    false,\n\t\t\texpectedOutput:   []string{\"No tag violations found\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"lower case\",\n\t\t\tfilePathOrDir:    \"testdata/cloudformation/tags.yml\",\n\t\t\tcliArgs:          []string{\"--tags\", \"owner\"},\n\t\t\texpectedExitCode: 1,\n\t\t\texpectedError:    true,\n\t\t\texpectedOutput:   []string{`AWS::S3::Bucket \"this\"`, \"Missing tags: owner\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\targsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)\n\t\t\toutput, err, exitCode := runTagNag(t, argsForRun...)\n\n\t\t\tif tc.expectedError && err == nil {\n\t\t\t\tt.Errorf(\"Expected an error from command execution, but got none. Output:\\n%s\", output)\n\t\t\t}\n\t\t\tif !tc.expectedError && err != nil {\n\t\t\t\tt.Errorf(\"Expected no error from command execution, but got: %v. Output:\\n%s\", err, output)\n\t\t\t}\n\n\t\t\tif exitCode != tc.expectedExitCode {\n\t\t\t\tt.Errorf(\"Expected exit code %d, got %d. Output:\\n%s\", tc.expectedExitCode, exitCode, output)\n\t\t\t}\n\n\t\t\tfor _, expectedStr := range tc.expectedOutput {\n\t\t\t\tif !strings.Contains(output, expectedStr) {\n\t\t\t\t\tt.Errorf(\"Output missing expected string '%s'. Output:\\n%s\", expectedStr, output)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "testdata/cloudformation/tags.json",
    "content": "{\n    \"AWSTemplateFormatVersion\": \"2010-09-09\",\n    \"Description\": \"single resource\",\n    \"Resources\": {\n        \"this\": {\n            \"Type\": \"AWS::S3::Bucket\",\n            \"Properties\": {\n                \"BucketName\": \"test-bucket\",\n                \"Tags\": [\n                    {\n                        \"Key\": \"Owner\",\n                        \"Value\": \"jakebark\"\n                    },\n                    {\n                        \"Key\": \"Environment\",\n                        \"Value\": \"dev\"\n                    }\n                ]\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "testdata/cloudformation/tags.yaml",
    "content": "AWSTemplateFormatVersion: '2010-09-09'\nDescription: single resource\nResources:\n  this:\n    Type: AWS::S3::Bucket\n    Properties:\n      BucketName: test-bucket\n      Tags:\n        - Key: Owner\n          Value: jakebark\n        - Key: Environment\n          Value: dev\n"
  },
  {
    "path": "testdata/cloudformation/tags.yml",
    "content": "AWSTemplateFormatVersion: '2010-09-09'\nDescription: single resource\nResources:\n  this:\n    Type: AWS::S3::Bucket\n    Properties:\n      BucketName: test-bucket\n      Tags:\n        - Key: Owner\n          Value: jakebark\n        - Key: Environment\n          Value: dev\n"
  },
  {
    "path": "testdata/config/blank_config.yml",
    "content": ""
  },
  {
    "path": "testdata/config/empty_settings.yml",
    "content": "tags:\n  - key: Owner\n\nsettings:\n\nskip:"
  },
  {
    "path": "testdata/config/full_config.yml",
    "content": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project\n\nsettings:\n  case_insensitive: true\n  dry_run: false\n  cfn_spec: \"/path/to/spec.json\"\n\nskip:\n  - \"*.tmp\"\n  - \".terraform\"\n  - \"test-data/**\""
  },
  {
    "path": "testdata/config/invalid_structure.yml",
    "content": "tags: \"Owner,Project\"\n"
  },
  {
    "path": "testdata/config/invalid_syntax.yml",
    "content": "tags:\n  - key: Owner\n    values: [Dev, Test\n"
  },
  {
    "path": "testdata/config/missing_tags.yml",
    "content": "settings:\n  dry_run: true\n\nskip:\n  - \"*.tmp\""
  },
  {
    "path": "testdata/config/tag_array.yml",
    "content": "tags:\n  - key: Owner\n    values: \"not-an-array\""
  },
  {
    "path": "testdata/config/tag_keys.yml",
    "content": "tags:\n  - key: Owner\n  - key: Project"
  },
  {
    "path": "testdata/config/tag_values.yml",
    "content": "tags:\n  - key: Owner\n  - key: Environment\n    values: [Dev, Test, Prod]\n  - key: Project"
  },
  {
    "path": "testdata/config/yaml_extension.yaml",
    "content": "tags:\n  - key: Owner"
  },
  {
    "path": "testdata/terraform/example_repo/locals.tf",
    "content": "locals {\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = var.environment\n    Source      = \"my-repo\"\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/example_repo/main.tf",
    "content": "resource \"aws_s3_bucket\" \"foo\" {\n  bucket = \"test-bucket\"\n}\n\nresource \"aws_s3_bucket\" \"bar\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Project = \"112233\"\n  }\n}\n\nresource \"aws_s3_bucket\" \"baz\" {\n  bucket   = \"test-bucket\"\n  provider = aws.west\n}\n"
  },
  {
    "path": "testdata/terraform/example_repo/provider.tf",
    "content": "terraform {\n  required_providers {\n    aws = {\n      source  = \"hashicorp/aws\"\n      version = \"~> 5.0\"\n    }\n  }\n}\n\nprovider \"aws\" {\n  region = \"us-east-1\"\n  default_tags {\n    tags = local.tags\n  }\n}\n\nprovider \"aws\" {\n  alias  = \"west\"\n  region = \"us-west-1\"\n}\n"
  },
  {
    "path": "testdata/terraform/example_repo/variables.tf",
    "content": "variable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n"
  },
  {
    "path": "testdata/terraform/functions.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = lower(\"Dev\")\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/ignore.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  #tag-nag ignore\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = \"dev\"\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/ignore_all.tf",
    "content": "#tag-nag ignore-all\nresource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n}\n"
  },
  {
    "path": "testdata/terraform/no_tags.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n}\n\n"
  },
  {
    "path": "testdata/terraform/provider.tf",
    "content": "provider \"aws\" {\n  region = \"us-east-1\"\n  default_tags {\n    tags = {\n      Project = \"112233\"\n      Source  = \"my-repo\"\n    }\n  }\n}\n\nresource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = \"dev\"\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/referenced_tags.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags   = var.tags\n}\n\nvariable \"tags\" {\n  type = map(string)\n  default = {\n    Owner       = \"jakebark\"\n    Environment = \"dev\"\n  }\n}\n"
  },
  {
    "path": "testdata/terraform/referenced_values.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = var.owner\n    Environment = local.environment\n    Project     = \"${local.project}\"\n    Source      = \"${local.source}\"\n  }\n}\n\nvariable \"owner\" {\n  type    = string\n  default = \"jakebark\"\n}\n\nvariable \"source\" {\n  type    = string\n  default = \"my-repo\"\n}\n\nlocals {\n  environment = \"dev\"\n  project     = \"112233\"\n  source      = var.source\n}\n"
  },
  {
    "path": "testdata/terraform/tags.tf",
    "content": "resource \"aws_s3_bucket\" \"this\" {\n  bucket = \"test-bucket\"\n  tags = {\n    Owner       = \"jakebark\"\n    Environment = \"dev\"\n  }\n}\n"
  }
]