diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c83583e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +terranix-codegen transforms Terraform provider schemas (go-cty type system) into type-safe NixOS modules for Terranix. It parses provider specs, runs `tofu providers schema`, then generates Nix code through a linear pipeline of AST transformations. + +## Build and Development + +```bash +nix develop # Enter dev shell (provides GHC, cabal, formatters, linters) +cabal build # Build the project +cabal test --enable-tests # Run all tests +cabal test --enable-coverage # Run tests with HPC coverage +nix build # Full Nix build +nix flake check # Run all checks (build, format, lint) +``` + +GHC 9.10.3 with GHC2024 edition. The flake uses flake-parts. + +## Testing + +Hspec with hspec-discover. Tests are in `test/TerranixCodegen/` with `*Spec.hs` suffix. + +Key testing patterns: + +- **`shouldMapTo` custom matcher** in `test/TestUtils.hs` — compares `NExpr` ASTs via pretty-printed output +- **QuasiQuotes** — tests use `[nix| ... |]` syntax for inline Nix AST literals +- HLint is configured (`.hlint.yaml`) to ignore parse errors from QuasiQuotes in test files + +## Formatting and Linting + +Treefmt-based with pre-commit hooks via hk. Hooks install automatically on dev shell entry. + +| Tool | Purpose | +| -------------------------- | ----------------------------------------------------------------------- | +| fourmolu | Haskell formatting (2-space indent, leading commas, record brace space) | +| hlint | Haskell linting | +| cabal-gild | .cabal file formatting | +| alejandra, deadnix, statix | Nix formatting and linting | + +## Architecture + +### Pipeline + +``` +ProviderSpec (text) → TerraformGenerator (runs tofu CLI) + → ProviderSchema (JSON via aeson) → TypeMapper (CtyType → NExpr) + → OptionBuilder (SchemaAttribute → mkOption) → ModuleGenerator (NixOS module assembly) + → FileOrganizer (directory tree output) +``` + +### Source Layout + +- `app/` — CLI entry point using optparse-applicative (`CLI/Types.hs`, `CLI/Parser.hs`, `CLI/Commands.hs`) +- `lib/TerranixCodegen/` — Library code (pipeline stages) + - `ProviderSchema/` — Aeson-based JSON parsing into ADTs (`CtyType.hs`, `Attribute.hs`, `Block.hs`, `Schema.hs`, `Provider.hs`, `Function.hs`, `Identity.hs`, `Types.hs`) + - `ProviderSpec.hs` — Megaparsec parser for provider specs (e.g. `hashicorp/aws:5.0.0`) + - `TypeMapper.hs` — Maps go-cty types to Nix types via hnix AST + - `OptionBuilder.hs` — Builds `mkOption` expressions from schema attributes + - `ModuleGenerator.hs` — Assembles complete NixOS module expressions + - `FileOrganizer.hs` — Writes organized directory tree with auto-generated `default.nix` imports + - `PrettyPrint.hs` — Colorized terminal output +- `test/` — Hspec test suite +- `nix/` — Flake modules (haskell build, devshell, treefmt, docs, CI workflow generation) +- `vendor/` — Vendored terraform-json schema library + +### Critical Design Decisions + +- **AST-based code generation, not string templates.** All Nix output goes through hnix's `NExpr` AST and `prettyNix`. This guarantees syntactic validity. Never generate Nix as raw strings. +- **`types.nullOr` for optional attributes** — preserves Terraform's null semantics (unset ≠ default value). +- **Custom `types.tupleOf`** — implemented in `nix/lib/tuple.nix` for fixed-length, per-position type validation matching Terraform tuples. +- **`StrictData` extension** — used in all `ProviderSchema/` modules for strict-by-default record fields. + +### Key Types + +| Type | Module | Role | +| ----------------- | -------------------------- | --------------------------------------------------------------------------------- | +| `CtyType` | `ProviderSchema.CtyType` | go-cty type system (Bool, Number, String, Dynamic, List, Set, Map, Object, Tuple) | +| `SchemaAttribute` | `ProviderSchema.Attribute` | Attribute metadata (type, required/optional, computed, deprecated, sensitive) | +| `SchemaBlock` | `ProviderSchema.Block` | Block with attributes, nested blocks, and nesting mode | +| `ProviderSpec` | `ProviderSpec` | Parsed provider specification (namespace/name:version) | +| `NExpr` | hnix | Nix expression AST — the core intermediate representation | + +## CI + +GitHub Actions workflow auto-generated from Nix (`nix/flake/ci/`). Three jobs: `check` (flake check), `build` (nix build), `coverage` (HPC report). diff --git a/lib/TerranixCodegen/Description.hs b/lib/TerranixCodegen/Description.hs new file mode 100644 index 0000000..8588171 --- /dev/null +++ b/lib/TerranixCodegen/Description.hs @@ -0,0 +1,119 @@ +module TerranixCodegen.Description ( + Description (..), + fromAttribute, + fromBlock, + fromText, + toNExpr, +) where + +import Data.Fix (Fix (..)) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Text qualified as T +import Nix.Expr.Shorthands +import Nix.Expr.Types (NExpr, NExprF (..)) + +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) + +-- | A description with text and format kind (plain or markdown). +data Description = Description + { descriptionText :: Text + , descriptionKind :: SchemaDescriptionKind + } + deriving stock (Show, Eq) + +-- | Create a Description from raw text and kind. +fromText :: Text -> SchemaDescriptionKind -> Description +fromText = Description + +{- | Build a Description from schema attribute metadata. + +Enriches with metadata notes: + - Deprecation warnings + - Sensitivity warnings + - Write-only notes + - Computed attribute notes + +Returns Nothing if all parts are empty. +-} +fromAttribute :: SchemaAttribute -> Maybe Description +fromAttribute attr + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (attributeDescriptionKind attr) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + -- Add trailing newline only for multi-line descriptions + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (attributeDescription attr) + , if fromMaybe False (attributeDeprecated attr) + then "DEPRECATED: This attribute is deprecated and may be removed in a future version." + else "" + , if fromMaybe False (attributeSensitive attr) + then "WARNING: This attribute contains sensitive information and will not be displayed in logs." + else "" + , if fromMaybe False (attributeWriteOnly attr) + then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." + else "" + , if fromMaybe False (attributeComputed attr) + && not (fromMaybe False (attributeRequired attr)) + && not (fromMaybe False (attributeOptional attr)) + then "This value is computed by the provider." + else "" + ] + +{- | Build a Description from a schema block. + +Enriches with deprecation note when blockDeprecated is set. +Returns Nothing if no description or metadata. +-} +fromBlock :: SchemaBlock -> Maybe Description +fromBlock block + | T.null combinedDesc = Nothing + | otherwise = Just $ Description finalDesc kind + where + kind = fromMaybe Plain (blockDescriptionKind block) + + nonEmptyParts = filter (not . T.null) parts + combinedDesc = T.intercalate "\n\n" nonEmptyParts + + finalDesc + | length nonEmptyParts > 1 = combinedDesc <> "\n" + | otherwise = combinedDesc + + parts = + [ fromMaybe "" (blockDescription block) + , if fromMaybe False (blockDeprecated block) + then "DEPRECATED: This block is deprecated and may be removed in a future version." + else "" + ] + +{- | Render a Description to a NExpr value. + +Rendering matrix: + - Plain + single-line -> mkStr text + - Plain + multi-line -> mkIndentedStr text + - Markdown + single-line -> lib.mdDoc "text" + - Markdown + multi-line -> lib.mdDoc ''text'' +-} +toNExpr :: Description -> NExpr +toNExpr (Description text kind) = + case kind of + Plain + | isMultiLine -> mkIndentedStr 16 text + | otherwise -> mkStr text + Markdown + | isMultiLine -> mdDoc (mkIndentedStr 16 text) + | otherwise -> mdDoc (mkStr text) + where + isMultiLine = T.any (== '\n') text + mdDoc = mkApp (Fix $ NSelect Nothing (mkSym "lib") (mkSelector "mdDoc")) diff --git a/lib/TerranixCodegen/ModuleGenerator.hs b/lib/TerranixCodegen/ModuleGenerator.hs index 203770a..e687ce3 100644 --- a/lib/TerranixCodegen/ModuleGenerator.hs +++ b/lib/TerranixCodegen/ModuleGenerator.hs @@ -15,6 +15,7 @@ import Data.Text (Text) import Nix.Expr.Shorthands import Nix.Expr.Types +import TerranixCodegen.Description qualified as Description import TerranixCodegen.OptionBuilder (attributesToSubmodule, buildOption) import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Block @@ -30,10 +31,13 @@ Creates a module with the structure: options.resource.{resourceType} = mkOption { type = types.attrsOf (types.submodule { options = {...}; }); default = {}; - description = "Instances of {resourceType}"; + description = ""; }; } +Uses the schema's block description when available, otherwise falls back to +"Instances of {resourceType}". + Example: generateResourceModule "aws" "aws_instance" schema -} @@ -82,7 +86,10 @@ generateResourceModule _providerName resourceType schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ "Instances of " <> resourceType) + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> resourceType + ) nullPos {- | Generate a complete NixOS module for a Terraform data source. @@ -135,7 +142,10 @@ generateDataSourceModule _providerName dataSourceType schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ "Instances of " <> dataSourceType <> " data source") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ "Instances of " <> dataSourceType <> " data source" + ) nullPos {- | Generate a complete NixOS module for a Terraform provider configuration. @@ -147,9 +157,12 @@ Creates a module with the structure: options.provider.{providerName} = mkOption { type = types.attrsOf (types.submodule { options = {...}; }); default = {}; - description = "{providerName} provider configuration"; + description = ""; }; } + +Uses the schema's block description when available, otherwise falls back to +"{providerName} provider configuration". -} generateProviderModule :: Text -> Schema -> NExpr generateProviderModule providerName schema = @@ -197,7 +210,10 @@ generateProviderModule providerName schema = descriptionBinding = NamedVar (mkSelector "description") - (mkStr $ providerName <> " provider configuration") + ( case schemaBlock schema >>= Description.fromBlock of + Just desc -> Description.toNExpr desc + Nothing -> mkStr $ providerName <> " provider configuration" + ) nullPos {- | Convert a SchemaBlock to a types.submodule expression. @@ -274,8 +290,8 @@ blockTypeToBinding name blockType = Just NestingMap -> Just $ NamedVar (mkSelector "default") (Fix $ NSet NonRecursive []) nullPos Nothing -> Just $ NamedVar (mkSelector "default") mkNull nullPos -- Default to Single behavior descriptionBinding = - case blockDescription (fromMaybe emptyBlock (blockTypeBlock blockType)) of - Just desc -> Just $ NamedVar (mkSelector "description") (mkStr desc) nullPos + case blockTypeBlock blockType >>= Description.fromBlock of + Just desc -> Just $ NamedVar (mkSelector "description") (Description.toNExpr desc) nullPos Nothing -> Nothing {- | Apply a nesting mode wrapper to a submodule type. @@ -374,14 +390,3 @@ nixTypes name = mkSym "types" `mkSelect` name -- | Helper to build a select expression (attribute access) mkSelect :: NExpr -> Text -> NExpr mkSelect expr attr = Fix $ NSelect Nothing expr (mkSelector attr) - --- | Empty SchemaBlock for fallback cases -emptyBlock :: SchemaBlock -emptyBlock = - SchemaBlock - { blockAttributes = Nothing - , blockNestedBlocks = Nothing - , blockDescription = Nothing - , blockDescriptionKind = Nothing - , blockDeprecated = Nothing - } diff --git a/lib/TerranixCodegen/OptionBuilder.hs b/lib/TerranixCodegen/OptionBuilder.hs index 71734a9..486f6aa 100644 --- a/lib/TerranixCodegen/OptionBuilder.hs +++ b/lib/TerranixCodegen/OptionBuilder.hs @@ -8,10 +8,10 @@ import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) import Data.Text (Text) -import Data.Text qualified as T import Nix.Expr.Shorthands import Nix.Expr.Types +import TerranixCodegen.Description qualified as Description import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) import TerranixCodegen.TypeMapper (mapCtyTypeToNixWithOptional) @@ -61,13 +61,12 @@ buildOption _attrName attr = -- Description binding (if description exists) descriptionBinding = - case buildDescription attr of + case Description.fromAttribute attr of Just desc -> Just $ NamedVar (mkSelector "description") - -- Use indented strings for multi-line, regular strings for single-line - (if T.any (== '\n') desc then mkIndentedStr 16 desc else mkStr desc) + (Description.toNExpr desc) nullPos Nothing -> Nothing @@ -140,62 +139,6 @@ buildDefault attr -- Otherwise no default | otherwise = Nothing -{- | Build a comprehensive description from schema metadata. - -Combines: - - Base description text - - Deprecation warnings - - Sensitivity warnings - - Write-only notes - - Computed attribute notes --} -buildDescription :: SchemaAttribute -> Maybe Text -buildDescription attr = - if T.null combinedDesc - then Nothing - else Just finalDesc - where - nonEmptyParts = filter (not . T.null) parts - combinedDesc = T.intercalate "\n\n" nonEmptyParts - - -- Add trailing newline only for multi-line descriptions (more than one part) - finalDesc = - if length nonEmptyParts > 1 - then combinedDesc <> "\n" - else combinedDesc - - parts = - [ baseDesc - , deprecationNote - , sensitiveNote - , writeOnlyNote - , computedNote - ] - - baseDesc = fromMaybe "" (attributeDescription attr) - - deprecationNote = - if fromMaybe False (attributeDeprecated attr) - then "DEPRECATED: This attribute is deprecated and may be removed in a future version." - else "" - - sensitiveNote = - if fromMaybe False (attributeSensitive attr) - then "WARNING: This attribute contains sensitive information and will not be displayed in logs." - else "" - - writeOnlyNote = - if fromMaybe False (attributeWriteOnly attr) - then "NOTE: This attribute is write-only and will not be persisted in the Terraform state." - else "" - - computedNote = - if fromMaybe False (attributeComputed attr) - && not (fromMaybe False (attributeRequired attr)) - && not (fromMaybe False (attributeOptional attr)) - then "This value is computed by the provider." - else "" - {- | Determine if an attribute should be marked as read-only. An attribute is read-only if it is computed but not required or optional. diff --git a/terranix-codegen.cabal b/terranix-codegen.cabal index aa6759a..6329906 100644 --- a/terranix-codegen.cabal +++ b/terranix-codegen.cabal @@ -21,6 +21,7 @@ library -- cabal-gild: discover import: warnings exposed-modules: + TerranixCodegen.Description TerranixCodegen.FileOrganizer TerranixCodegen.ModuleGenerator TerranixCodegen.OptionBuilder @@ -97,6 +98,7 @@ test-suite terranix-codegen-tests type: exitcode-stdio-1.0 main-is: Main.hs other-modules: + TerranixCodegen.DescriptionSpec TerranixCodegen.FileOrganizerSpec TerranixCodegen.ModuleGeneratorSpec TerranixCodegen.OptionBuilderSpec diff --git a/test/TerranixCodegen/DescriptionSpec.hs b/test/TerranixCodegen/DescriptionSpec.hs new file mode 100644 index 0000000..8b19502 --- /dev/null +++ b/test/TerranixCodegen/DescriptionSpec.hs @@ -0,0 +1,164 @@ +module TerranixCodegen.DescriptionSpec (spec) where + +import Data.Text qualified as T +import Nix.TH (nix) +import Test.Hspec + +import TerranixCodegen.Description +import TerranixCodegen.ProviderSchema.Attribute +import TerranixCodegen.ProviderSchema.Block +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..)) +import TestUtils (shouldMapTo) + +emptyAttr :: SchemaAttribute +emptyAttr = + SchemaAttribute + { attributeType = Nothing + , attributeNestedType = Nothing + , attributeDescription = Nothing + , attributeDescriptionKind = Nothing + , attributeDeprecated = Nothing + , attributeRequired = Nothing + , attributeOptional = Nothing + , attributeComputed = Nothing + , attributeSensitive = Nothing + , attributeWriteOnly = Nothing + } + +emptyBlock :: SchemaBlock +emptyBlock = + SchemaBlock + { blockAttributes = Nothing + , blockNestedBlocks = Nothing + , blockDescription = Nothing + , blockDescriptionKind = Nothing + , blockDeprecated = Nothing + } + +spec :: Spec +spec = do + describe "fromAttribute" $ do + it "extracts plain description" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown description" $ do + let attr = + emptyAttr + { attributeDescription = Just "hello" + , attributeDescriptionKind = Just Markdown + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "hello" + descriptionKind desc `shouldBe` Markdown + + it "defaults to Plain when kind not specified" $ do + let attr = emptyAttr {attributeDescription = Just "hello"} + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Plain + + it "returns Nothing when no description or metadata" $ do + fromAttribute emptyAttr `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "enriches with sensitivity warning" $ do + let attr = + emptyAttr + { attributeDescription = Just "Secret" + , attributeSensitive = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "WARNING" + + it "enriches with write-only note" $ do + let attr = + emptyAttr + { attributeDescription = Just "Token" + , attributeWriteOnly = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "write-only" + + it "enriches with computed note for computed-only attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "ID" + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "computed by the provider" + + it "does not add computed note for optional+computed attributes" $ do + let attr = + emptyAttr + { attributeDescription = Just "IP" + , attributeOptional = Just True + , attributeComputed = Just True + } + let Just desc = fromAttribute attr + descriptionText desc `shouldBe` "IP" + + it "preserves markdown kind with metadata enrichment" $ do + let attr = + emptyAttr + { attributeDescription = Just "Old field" + , attributeDescriptionKind = Just Markdown + , attributeDeprecated = Just True + } + let Just desc = fromAttribute attr + descriptionKind desc `shouldBe` Markdown + + it "creates description from metadata alone" $ do + let attr = emptyAttr {attributeDeprecated = Just True} + let Just desc = fromAttribute attr + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "fromBlock" $ do + it "extracts plain block description" $ do + let block = emptyBlock {blockDescription = Just "A block"} + let Just desc = fromBlock block + descriptionText desc `shouldBe` "A block" + descriptionKind desc `shouldBe` Plain + + it "extracts markdown block description" $ do + let block = + emptyBlock + { blockDescription = Just "A block" + , blockDescriptionKind = Just Markdown + } + let Just desc = fromBlock block + descriptionKind desc `shouldBe` Markdown + + it "returns Nothing for empty block" $ do + fromBlock emptyBlock `shouldBe` Nothing + + it "enriches with deprecation note" $ do + let block = + emptyBlock + { blockDescription = Just "Old block" + , blockDeprecated = Just True + } + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + it "creates description from deprecated flag alone" $ do + let block = emptyBlock {blockDeprecated = Just True} + let Just desc = fromBlock block + descriptionText desc `shouldSatisfy` T.isInfixOf "DEPRECATED" + + describe "toNExpr" $ do + it "renders plain single-line as regular string" $ do + toNExpr (fromText "hello" Plain) `shouldMapTo` [nix| "hello" |] + + it "renders markdown single-line with lib.mdDoc" $ do + toNExpr (fromText "hello" Markdown) `shouldMapTo` [nix| lib.mdDoc "hello" |] diff --git a/test/TerranixCodegen/ModuleGeneratorSpec.hs b/test/TerranixCodegen/ModuleGeneratorSpec.hs index eac2f38..82397bb 100644 --- a/test/TerranixCodegen/ModuleGeneratorSpec.hs +++ b/test/TerranixCodegen/ModuleGeneratorSpec.hs @@ -9,7 +9,7 @@ import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.Block import TerranixCodegen.ProviderSchema.CtyType import TerranixCodegen.ProviderSchema.Schema -import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) import TestUtils (shouldMapTo) -- | Helper to create an empty SchemaAttribute @@ -277,6 +277,95 @@ spec = do } |] + describe "block descriptions" $ do + it "adds deprecation note to nested block description" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Old monitoring config" + , blockDeprecated = Just True + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = '' + Old monitoring config + + DEPRECATED: This block is deprecated and may be removed in a future version. + ''; + }; + }; + } + |] + + it "wraps markdown block description with lib.mdDoc" $ do + let nestedBlock = + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("enabled", emptyAttr {attributeType = Just CtyBool, attributeRequired = Just True})] + , blockDescription = Just "Enable monitoring" + , blockDescriptionKind = Just Markdown + } + blockType = + SchemaBlockType + { blockTypeNestingMode = Just NestingSingle + , blockTypeBlock = Just nestedBlock + , blockTypeMinItems = Nothing + , blockTypeMaxItems = Nothing + } + block = + emptyBlock + { blockNestedBlocks = + Just $ + Map.fromList [("monitoring", blockType)] + } + blockToSubmodule block + `shouldMapTo` [nix| + types.submodule { + options = { + monitoring = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; + }; + }; + }; + default = null; + description = lib.mdDoc "Enable monitoring"; + }; + }; + } + |] + describe "generateResourceModule" $ do it "generates complete resource module with simple attributes" $ do let schema = @@ -374,6 +463,38 @@ spec = do } |] + it "uses schema block description for resource module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Manages a compute instance" + } + } + generateResourceModule "aws" "aws_instance" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.resource.aws_instance = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Manages a compute instance"; + }; + } + |] + describe "generateDataSourceModule" $ do it "generates complete data source module" $ do let schema = @@ -406,6 +527,38 @@ spec = do } |] + it "uses schema block description for data source module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("name", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "Fetches AMI information" + } + } + generateDataSourceModule "aws" "aws_ami" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.data.aws_ami = mkOption { + type = types.attrsOf (types.submodule { + options = { + name = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "Fetches AMI information"; + }; + } + |] + describe "generateProviderModule" $ do it "generates complete provider configuration module" $ do let schema = @@ -443,3 +596,35 @@ spec = do }; } |] + + it "uses schema block description for provider module" $ do + let schema = + emptySchema + { schemaBlock = + Just $ + emptyBlock + { blockAttributes = + Just $ + Map.fromList + [("region", emptyAttr {attributeType = Just CtyString, attributeRequired = Just True})] + , blockDescription = Just "The AWS provider configuration" + } + } + generateProviderModule "aws" schema + `shouldMapTo` [nix| + { lib, ... }: + with lib; + { + options.provider.aws = mkOption { + type = types.attrsOf (types.submodule { + options = { + region = mkOption { + type = types.str; + }; + }; + }); + default = {}; + description = "The AWS provider configuration"; + }; + } + |] diff --git a/test/TerranixCodegen/OptionBuilderSpec.hs b/test/TerranixCodegen/OptionBuilderSpec.hs index 43e7c99..cf83354 100644 --- a/test/TerranixCodegen/OptionBuilderSpec.hs +++ b/test/TerranixCodegen/OptionBuilderSpec.hs @@ -7,7 +7,7 @@ import Test.Hspec import TerranixCodegen.OptionBuilder import TerranixCodegen.ProviderSchema.Attribute import TerranixCodegen.ProviderSchema.CtyType -import TerranixCodegen.ProviderSchema.Types (SchemaNestingMode (..)) +import TerranixCodegen.ProviderSchema.Types (SchemaDescriptionKind (..), SchemaNestingMode (..)) import TestUtils (shouldMapTo) -- | Helper to create a minimal SchemaAttribute @@ -403,6 +403,45 @@ spec = do } |] + describe "markdown descriptions" $ do + it "wraps markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeRequired = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.str; + description = lib.mdDoc "The AMI ID"; + } + |] + + it "wraps multi-line markdown description with lib.mdDoc" $ do + let attr = + emptyAttr + { attributeType = Just CtyString + , attributeDescription = Just "The AMI ID" + , attributeDescriptionKind = Just Markdown + , attributeComputed = Just True + } + buildOption "ami" attr + `shouldMapTo` [nix| + mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The AMI ID + + This value is computed by the provider. + ''; + readOnly = true; + } + |] + describe "real-world examples" $ do it "handles AWS instance AMI attribute" $ do let attr =