diff --git a/docs/docker-to-iac/api.md b/docs/docker-to-iac/api.md index 031d451..320be0c 100644 --- a/docs/docker-to-iac/api.md +++ b/docs/docker-to-iac/api.md @@ -29,29 +29,40 @@ console.log(parsers); { providerWebsite: 'https://aws.amazon.com/cloudformation/', providerName: 'Amazon Web Services', - provieerNameAbbreviation: 'AWS', + providerNameAbbreviation: 'AWS', languageOfficialDocs: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html', languageAbbreviation: 'CFN', languageName: 'AWS CloudFormation', - defaultParserConfig: { fileName: 'aws-cloudformation.yaml', cpu: 512, memory: '1GB', templateFormat: 'yaml' } + defaultParserConfig: { files: [Array], cpu: 512, memory: '1GB' } }, { providerWebsite: 'https://render.com/docs', providerName: 'Render', - provieerNameAbbreviation: 'RND', + providerNameAbbreviation: 'RND', languageOfficialDocs: 'https://docs.render.com/infrastructure-as-code', languageAbbreviation: 'RND', languageName: 'Render Blue Print', defaultParserConfig: { - subscriptionName: 'free', + files: [Array], + subscriptionName: 'starter', region: 'oregon', - fileName: 'render.yaml', - templateFormat: 'yaml' + diskSizeGB: 10 } + }, + { + providerWebsite: 'https://www.digitalocean.com/', + providerName: 'DigitalOcean', + providerNameAbbreviation: 'DO', + languageOfficialDocs: 'https://docs.digitalocean.com/products/app-platform/', + languageAbbreviation: 'DOP', + languageName: 'DigitalOcean App Spec', + defaultParserConfig: { files: [Array], region: 'nyc', subscriptionName: 'basic-xxs' } } ] ``` +**Note the files array**: that's because we have a [multi file strategy](/docs/docker-to-iac/multi-file-configuration.md). + ### Type ```typescript @@ -77,13 +88,24 @@ console.log(awsInfo); ```json { - providerWebsite: 'https://aws.amazon.com/cloudformation/', - providerName: 'Amazon Web Services', - provieerNameAbbreviation: 'AWS', - languageOfficialDocs: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html', - languageAbbreviation: 'CFN', - languageName: 'AWS CloudFormation', - defaultParserConfig: { fileName: 'aws-cloudformation.yaml', cpu: 512, memory: '1GB', templateFormat: 'yaml' } + providerWebsite: 'https://aws.amazon.com/cloudformation/', + providerName: 'Amazon Web Services', + providerNameAbbreviation: 'AWS', + languageOfficialDocs: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html', + languageAbbreviation: 'CFN', + languageName: 'AWS CloudFormation', + defaultParserConfig: { + files: [ + { + path: 'aws-cloudformation.cf.yml', + templateFormat: 'yaml', + isMain: true, + description: 'AWS CloudFormation template' + } + ], + cpu: 512, + memory: '1GB' + } } ``` @@ -107,7 +129,23 @@ translate(input: string, options: { environmentVariableGeneration?: EnvironmentVariableGenerationConfig; environmentVariables?: Record; persistenceKey?: string; -}): any +}): TranslationResult +``` + +Where `TranslationResult` has the structure: + +```typescript +interface TranslationResult { + files: { + [path: string]: FileOutput + }; +} + +interface FileOutput { + content: string; + format: TemplateFormat; + isMain?: boolean; +} ``` ### Examples @@ -115,67 +153,93 @@ translate(input: string, options: { #### Translating Docker Compose ```javascript -import { readFileSync, writeFileSync } from 'fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; import { translate } from '@deploystack/docker-to-iac'; const dockerComposeContent = readFileSync('path/to/docker-compose.yml', 'utf8'); -const translatedConfig = translate(dockerComposeContent, { +const result = translate(dockerComposeContent, { source: 'compose', target: 'CFN', templateFormat: 'yaml' }); -console.log(translatedConfig); + +// Access individual file contents +console.log(`Generated ${Object.keys(result.files).length} files:`); +Object.keys(result.files).forEach(path => { + console.log(`- ${path}`); +}); + +// Write files to disk preserving directory structure +Object.entries(result.files).forEach(([path, fileData]) => { + const fullPath = join('output', path); + const dir = dirname(fullPath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(fullPath, fileData.content); +}); ``` #### Translating Docker Run Command ```javascript import { translate } from '@deploystack/docker-to-iac'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; const dockerRunCommand = 'docker run -d -p 8080:80 nginx:latest'; -const translatedConfig = translate(dockerRunCommand, { +const result = translate(dockerRunCommand, { source: 'run', - target: 'CFN', + target: 'RND', templateFormat: 'yaml' }); -console.log(translatedConfig); + +console.log(result) + +// Access and save all generated files +Object.entries(result.files).forEach(([path, fileData]) => { + const fullPath = join('output', path); + const dir = dirname(fullPath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(fullPath, fileData.content); + console.log(`Created: ${path}`); +}); ``` ### Example Output (AWS CloudFormation) ```yaml -AWSTemplateFormatVersion: 2010-09-09 -Description: Generated from container configuration by docker-to-iac -Parameters: - VPC: - Type: AWS::EC2::VPC::Id - SubnetA: - Type: AWS::EC2::Subnet::Id - SubnetB: - Type: AWS::EC2::Subnet::Id - ServiceName: - Type: String - Default: DeployStackService -Resources: - Cluster: - Type: AWS::ECS::Cluster - Properties: - ClusterName: !Join ['', [!Ref ServiceName, Cluster]] - ExecutionRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Join ['', [!Ref ServiceName, ExecutionRole]] - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: ecs-tasks.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy -... +{ + files: { + 'render.yaml': { + content: 'services:\n' + + ' - name: default\n' + + ' type: web\n' + + ' env: docker\n' + + ' runtime: image\n' + + ' image:\n' + + ' url: docker.io/library/nginx:latest\n' + + ' startCommand: ""\n' + + ' plan: starter\n' + + ' region: oregon\n' + + ' envVars:\n' + + ' - key: PORT\n' + + ' value: "80"\n', + format: 'yaml', + isMain: true + } + } +} +Created: render.yaml ``` #### Translation with Environment Variable Generation diff --git a/docs/docker-to-iac/example-of-a-new-parser.md b/docs/docker-to-iac/example-of-a-new-parser.md index b105837..1bcdfe7 100644 --- a/docs/docker-to-iac/example-of-a-new-parser.md +++ b/docs/docker-to-iac/example-of-a-new-parser.md @@ -1,12 +1,12 @@ --- -description: Example code for adding a new parser to docker-to-iac, supporting both Docker run commands and Docker Compose files +description: Example code for adding a new parser to docker-to-iac, supporting both Docker run commands and Docker Compose files, with multi-file output capabilities menuTitle: Adding a New Parser --- # Adding a New Parser > [!TIP] -> __Thank you__ for your interest in collaborating! The docker-to-iac module will remain open source forever, helping simplify deployments across cloud providers without vendor lock-in. +> Thank you for your interest in collaborating! The docker-to-iac module will remain open source forever, helping simplify deployments across cloud providers without vendor lock-in. ## Parser Implementation @@ -16,32 +16,59 @@ Create a new file inside `src/parsers/new-provider.ts`: import { BaseParser, ParserInfo, - ContainerConfig, TemplateFormat, - formatResponse, - DefaultParserConfig + ParserConfig, + FileOutput } from './base-parser'; - -const defaultParserConfig: DefaultParserConfig = { +import { ApplicationConfig } from '../types/container-config'; + +const defaultParserConfig: ParserConfig = { + files: [ + { + path: 'awesome-iac.yaml', + templateFormat: TemplateFormat.yaml, + isMain: true, + description: 'Main IaC configuration file' + } + ], cpu: 512, - memory: '1GB', - fileName: 'awesome-iac.yaml', - templateFormat: TemplateFormat.yaml + memory: '1GB' }; class NewProviderParser extends BaseParser { - parse(containerConfig: ContainerConfig, templateFormat: TemplateFormat = defaultParserConfig.templateFormat): any { - let response: any = {}; - - // Get container configurations - const services = containerConfig.services; + // Legacy method implementation (calls parseFiles under the hood) + parse(config: ApplicationConfig, templateFormat: TemplateFormat = TemplateFormat.yaml): any { + return super.parse(config, templateFormat); + } + + // New multi-file implementation + parseFiles(config: ApplicationConfig): { [path: string]: FileOutput } { + // Process the application configuration + const services = config.services; // Your parser implementation here: // 1. Process each service // 2. Map container configurations to your IaC format // 3. Handle provider-specific requirements - return formatResponse(JSON.stringify(response, null, 2), templateFormat); + // Create your primary IaC configuration + const primaryConfig = { + // Your main configuration structure + }; + + // Return object with file mappings - at minimum return your main file + return { + 'awesome-iac.yaml': { + content: this.formatFileContent(primaryConfig, TemplateFormat.yaml), + format: TemplateFormat.yaml, + isMain: true + } + // Add additional files as needed: + // 'templates/service.yaml': { + // content: this.formatFileContent(serviceConfig, TemplateFormat.yaml), + // format: TemplateFormat.yaml + // } + }; } getInfo(): ParserInfo { @@ -62,22 +89,34 @@ export default new NewProviderParser(); ## Parser Configuration -### Default Parser Config +### Multi-File Configuration + +Define the files your parser will generate, please read more here: [Multi-File Configuration in docker-to-iac](/docs/docker-to-iac/multi-file-configuration.md). + +### File Output -Set appropriate defaults for your cloud provider: +Your parser must implement the `parseFiles` method which returns a mapping of file paths to content: ```typescript -const defaultParserConfig: DefaultParserConfig = { - cpu: 512, // Minimum viable CPU allocation - memory: '1GB', // Minimum viable memory - fileName: 'awesome-iac.yaml', // Default output filename - templateFormat: TemplateFormat.yaml // Default format -}; +parseFiles(config: ApplicationConfig): { [path: string]: FileOutput } { + return { + 'awesome-iac.yaml': { + content: this.formatFileContent(mainConfig, TemplateFormat.yaml), + format: TemplateFormat.yaml, + isMain: true // Mark one file as the main file + }, + 'templates/deployment.yaml': { + content: this.formatFileContent(deploymentConfig, TemplateFormat.yaml), + format: TemplateFormat.yaml + } + // Add more files as needed + }; +} ``` -Choose conservative resource defaults to prevent unexpected costs for users. +The `isMain: true` property is required for at least one file - this maintains backward compatibility with existing code. -### Supported Formats +### Supported Ouput Formats Select appropriate output formats for your provider: @@ -143,7 +182,7 @@ npm run build - Include examples for both Docker run and Docker Compose - Follow [documentation guidelines](https://github.com/deploystackio/documentation/blob/main/README.md) -## Best Practices +## Checlist 1. Support both input types: - Docker run commands diff --git a/docs/docker-to-iac/multi-file-configuration.md b/docs/docker-to-iac/multi-file-configuration.md new file mode 100644 index 0000000..9e15094 --- /dev/null +++ b/docs/docker-to-iac/multi-file-configuration.md @@ -0,0 +1,168 @@ +--- +description: Learn how docker-to-iac supports complex Infrastructure as Code templates with multiple interconnected files, including Helm Charts and other multi-file IaC formats. +menuTitle: Multi-File Configuration +--- + +# Multi-File Configuration in docker-to-iac + +## Introduction to Multi-File Support + +Starting with version 1.20.0, [docker-to-iac](https://github.com/deploystackio/docker-to-iac) supports generating multiple interconnected files for more complex Infrastructure as Code (IaC) templates. This feature was introduced primarily to support Helm Charts and other sophisticated IaC formats that require multiple files with specific directory structures. + +## Why Multi-File Templates? + +Modern IaC solutions often require multiple files that work together: + +- **Helm Charts** need Chart.yaml, values.yaml, and template files +- **Terraform modules** use main.tf, variables.tf, outputs.tf, and more +- **Kubernetes manifests** are typically split into multiple YAML files +- **Multi-tier applications** may need separate configurations for each tier + +These complex deployments would be difficult or impossible to represent in a single file, which led to the introduction of multi-file support. + +## The Main File Concept + +Each parser must designate one file as the "main" file using the `isMain: true` property. This maintains backward compatibility with existing code and provides a clear entry point for deployment tools. + +```typescript +parseFiles(config: ApplicationConfig): { [path: string]: FileOutput } { + return { + 'Chart.yaml': { + content: this.formatFileContent(chartConfig, TemplateFormat.yaml), + format: TemplateFormat.yaml, + isMain: true // This is the main file + }, + 'values.yaml': { + content: this.formatFileContent(valuesConfig, TemplateFormat.yaml), + format: TemplateFormat.yaml + } + }; +} +``` + +When a parser is invoked through the legacy `parse()` method, only the content of the main file is returned. However, when using the `parseFiles()` method, all files are included in the response. + +## Example: Helm Chart Structure + +Helm Charts are a perfect example of why multi-file support is needed. A basic Helm Chart requires at least the following files: + +```bash +mychart/ +├── Chart.yaml # Chart metadata +├── values.yaml # Default configuration values +└── templates/ + ├── deployment.yaml # Kubernetes Deployment + ├── service.yaml # Kubernetes Service + └── _helpers.tpl # Template helpers +``` + +With the multi-file support, a Helm Chart parser configuration might look like: + +```typescript +const defaultParserConfig: ParserConfig = { + files: [ + { + path: 'Chart.yaml', + templateFormat: TemplateFormat.yaml, + isMain: true, + description: 'Chart metadata file' + }, + { + path: 'values.yaml', + templateFormat: TemplateFormat.yaml, + description: 'Default configuration values' + }, + { + path: 'templates/deployment.yaml', + templateFormat: TemplateFormat.yaml, + description: 'Kubernetes Deployment template' + }, + { + path: 'templates/service.yaml', + templateFormat: TemplateFormat.yaml, + description: 'Kubernetes Service template' + }, + { + path: 'templates/_helpers.tpl', + templateFormat: TemplateFormat.text, + description: 'Template helper functions' + } + ] +}; +``` + +## Implementation Details + +### File Structure + +Each parser must implement the `parseFiles` method, which returns an object mapping file paths to their content: + +```typescript +interface FileOutput { + content: string; // File content as a string + format: TemplateFormat; // Format (yaml, json, text) + isMain?: boolean; // Whether this is the main file +} + +parseFiles(config: ApplicationConfig): { [path: string]: FileOutput }; +``` + +### Directory Support + +The file paths can include directories, which will be created automatically when the templates are saved: + +```typescript +return { + 'templates/deployment.yaml': { + content: deploymentContent, + format: TemplateFormat.yaml + }, + 'templates/ingress.yaml': { + content: ingressContent, + format: TemplateFormat.yaml + } +}; +``` + +### Content Formatting + +The `formatFileContent` helper method ensures that content is properly formatted according to the specified template format. + +## Backward Compatibility + +To maintain backward compatibility, the `BaseParser` class implements a default `parse` method that: + +1. Calls the new `parseFiles` method +2. Finds the file marked with `isMain: true` +3. Returns only that file's content + +```typescript +parse(config: ApplicationConfig, templateFormat?: TemplateFormat): any { + const files = this.parseFiles(config); + const mainFile = Object.values(files).find(file => file.isMain); + + if (!mainFile) { + throw new Error('No main file defined in parser output'); + } + + return typeof mainFile.content === 'string' + ? mainFile.content + : formatResponse(JSON.stringify(mainFile.content, null, 2), templateFormat || mainFile.format); +} +``` + +This ensures that existing code that calls `parse()` will continue to work as expected. + +## Best Practices + +When implementing multi-file parsers: + +1. **Always mark one file as main**: Designate exactly one file as `isMain: true` to maintain backward compatibility. + +2. **Use consistent directory structure**: Follow the conventions of your target IaC format (e.g., Helm Chart layout). + +3. **Use appropriate formats**: Choose the right format for each file (YAML for Kubernetes manifests, text for template helpers, etc.). + +4. **Include descriptive comments**: Add descriptions to help users understand the purpose of each file. + +5. **Handle file dependencies**: Ensure that files reference each other correctly using relative paths. diff --git a/docs/docker-to-iac/parser/aws-cloudformation.md b/docs/docker-to-iac/parser/aws-cloudformation.md index 5f30dd9..d8af07c 100644 --- a/docs/docker-to-iac/parser/aws-cloudformation.md +++ b/docs/docker-to-iac/parser/aws-cloudformation.md @@ -53,6 +53,14 @@ ReadonlyRootFilesystem: false - The default output format for this parser: `YAML`. +## File Configuration + +The AWS CloudFormation parser generates a single consolidated template: + +- `aws-cloudformation.cf.yml` - The comprehensive CloudFormation template that defines all resources including ECS clusters, services, tasks, security groups, and IAM roles + +This single-file approach encapsulates the entire infrastructure definition in YAML format, making it ready for immediate deployment through the AWS CloudFormation console, CLI, or other AWS deployment tools. + ## Supported Docker Compose Variables The current version supports the following Docker Compose variables: diff --git a/docs/docker-to-iac/parser/digitalocean.md b/docs/docker-to-iac/parser/digitalocean.md index eeb126b..2f224ec 100644 --- a/docs/docker-to-iac/parser/digitalocean.md +++ b/docs/docker-to-iac/parser/digitalocean.md @@ -57,6 +57,14 @@ After deployment, all services can be monitored and managed through your Digital - The default output format for this parser: `YAML`. +## File Configuration + +The DigitalOcean parser generates a structured output with a specific file organization: + +- `.do/deploy.template.yaml` - The main App Platform specification file that defines all services, environment variables, and configuration options for deployment + +This single-file structure follows DigitalOcean's App Platform requirements, where all deployment configurations are contained within the standard location expected by the DigitalOcean CLI and deployment tools. + ## Supported Docker Compose Variables This parser supports the following Docker Compose variables for services: diff --git a/docs/docker-to-iac/parser/render.com.md b/docs/docker-to-iac/parser/render.com.md index ff5b692..b9e169b 100644 --- a/docs/docker-to-iac/parser/render.com.md +++ b/docs/docker-to-iac/parser/render.com.md @@ -33,6 +33,14 @@ In contrast to other cloud providers, Render.com's usability is very trivial. Th - The default output format for this parser: `YAML`. +## File Configuration + +The Render.com parser generates a single file output: + +- `render.yaml` - The main Blueprint configuration file that defines all services, environment variables, and disk configurations + +This straightforward single-file approach aligns with Render's Blueprint specification, which requires all service definitions to be contained within a single YAML file. The file is structured according to Render's requirements with services, environment variables, and disk configurations properly organized for immediate deployment. + ## Supported Docker Compose Variables The current version supports the following Docker Compose variables: diff --git a/docs/docker-to-iac/testing.md b/docs/docker-to-iac/testing.md index c45369f..6a1a9b4 100644 --- a/docs/docker-to-iac/testing.md +++ b/docs/docker-to-iac/testing.md @@ -1,11 +1,11 @@ --- -description: Learn how to test the docker-to-iac module including Docker run commands and Docker Compose files. Complete guide for local testing, automated checks, and CI/CD integration. +description: Learn how to test the docker-to-iac module including Docker run commands and Docker Compose files, with support for multi-file output generation and directory structure preservation. menuTitle: Testing --- # Testing docker-to-iac Module -Before submitting a pull request, test your code locally. Testing covers both code quality and functional aspects for Docker run commands and Docker Compose files. +Before submitting a pull request, test your code locally. Testing covers both code quality and functional aspects for Docker run commands and Docker Compose files, including the new multi-file output capabilities. ## Running Tests @@ -27,7 +27,7 @@ Run the test suite: npm run test ``` -The test suite runs comprehensive checks across all parsers and formats. Testing structure: +The test suite runs comprehensive checks across all parsers and formats, with support for multi-file outputs. Testing structure: ```bash test/ @@ -43,15 +43,23 @@ test/ │ ├── docker-compose/ # Docker Compose test outputs │ │ └── [filename]/ │ │ ├── services.json -│ │ ├── cfn/ # AWS CloudFormation -│ │ ├── rnd/ # Render -│ │ └── dop/ # DigitalOcean +│ │ ├── cfn/ # AWS CloudFormation outputs +│ │ │ └── aws-cloudformation.cf.yml +│ │ ├── rnd/ # Render outputs +│ │ │ └── render.yaml +│ │ └── dop/ # DigitalOcean outputs +│ │ └── .do/ +│ │ └── deploy.template.yaml │ └── docker-run/ # Docker run test outputs │ └── [filename]/ │ ├── services.json │ ├── cfn/ +│ │ └── aws-cloudformation.cf.yml │ ├── rnd/ +│ │ └── render.yaml │ └── dop/ +│ └── .do/ +│ └── deploy.template.yaml └── test.ts # Main test file ``` @@ -61,6 +69,7 @@ The test suite automatically: - Processes all Docker Compose files in `test/docker-compose-files/` - Processes all Docker run commands in `test/docker-run-files/` - Generates outputs in all supported formats (JSON, YAML, text) +- Preserves proper directory structure for multi-file outputs - Validates parser information and service listing - Creates organized output directories for inspection @@ -91,6 +100,33 @@ When adding a new parser: 3. The test suite will automatically include your parser in testing 4. Check outputs in `test/output/docker-compose/` and `test/output/docker-run/` +For parsers that generate multiple files: + +1. Ensure your `parseFiles` method returns the correct file structure +2. The test suite will automatically preserve directory structure +3. Verify that nested directories are created correctly in the output + +## Examining Test Output + +After running tests, check the output directory structure to verify that your parser correctly generates files: + +```bash +$ tree -a test/output/docker-run/simple-1/ +test/output/docker-run/simple-1/ +├── cfn +│ └── aws-cloudformation.cf.yml +├── dop +│ └── .do +│ └── deploy.template.yaml +├── rnd +│ └── render.yaml +└── services.json + +5 directories, 4 files +``` + +This shows how each parser generates its files with the appropriate directory structure. + ## Local Testing with `npm link` Test locally using `npm link`. Development environment setup: @@ -119,31 +155,59 @@ Setup steps: ```javascript import { translate } from '@deploystack/docker-to-iac'; -import { readFileSync } from 'fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; // Test Docker Compose const dockerComposeContent = readFileSync('docker-compose.yml', 'utf8'); -const composeConfig = translate(dockerComposeContent, { +const composeResult = translate(dockerComposeContent, { source: 'compose', target: 'CFN', templateFormat: 'yaml' }); -console.log('Docker Compose Translation:', composeConfig); + +console.log('Docker Compose Translation - Files:', Object.keys(composeResult.files)); + +// Write output files preserving directory structure +Object.entries(composeResult.files).forEach(([path, fileData]) => { + const fullPath = join('output', 'compose', path); + const dir = dirname(fullPath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(fullPath, fileData.content); +}); // Test Docker Run const dockerRunContent = readFileSync('docker-run.txt', 'utf8'); -const runConfig = translate(dockerRunContent, { +const runResult = translate(dockerRunContent, { source: 'run', target: 'CFN', templateFormat: 'yaml' }); -console.log('Docker Run Translation:', runConfig); + +console.log('Docker Run Translation - Files:', Object.keys(runResult.files)); + +// Write output files preserving directory structure +Object.entries(runResult.files).forEach(([path, fileData]) => { + const fullPath = join('output', 'run', path); + const dir = dirname(fullPath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(fullPath, fileData.content); +}); ``` 3. Test changes: - Make changes in `docker-to-iac/` - Run `npm run build` in docker-to-iac - Test in `my-dev-env/` with `node index.js` + - Check the output directory for generated files ## Test Results @@ -153,4 +217,4 @@ The test suite shows success (✓) or failure (❌) for each test. On failure: - Process exits with code 1 - GitHub Actions fails PR check -Check both Docker Compose and Docker run outputs in `test/output/` to verify your parser produces expected results across all formats. +Check both Docker Compose and Docker run outputs in `test/output/` to verify your parser produces expected results across all formats and maintains the correct directory structure for multi-file outputs.