Repository: aksiksi/compose2nix
Pull request #33 (2024-08-10 01:05:47)

 feat: option to auto-format Nix output using nixfmt 
TODO:

- [x] Figure out how to get auto-format test to pass during Flake build. For some reason, `nixfmt` isn't being included during Flake build.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) Report
Attention: Patch coverage is `9.09091%` with `50 lines` in your changes missing coverage. Please review.
> Project coverage is 70.67%. Comparing base [(`e5f3568`)](https://app.codecov.io/gh/aksiksi/compose2nix/commit/e5f35685dd09435f648ec827335ab307b7c4574d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) to head [(`35b18e2`)](https://app.codecov.io/gh/aksiksi/compose2nix/commit/35b18e2705e2014a46c1cc1c21e82c8789adc0a7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).

| [Files](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) | Patch % | Lines |
|---|---|---|
| [helpers.go](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&filepath=helpers.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi#diff-aGVscGVycy5nbw==) | 0.00% | [26 Missing :warning: ](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) |
| [main.go](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&filepath=main.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi#diff-bWFpbi5nbw==) | 0.00% | [15 Missing :warning: ](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) |
| [nix.go](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&filepath=nix.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi#diff-bml4Lmdv) | 30.76% | [7 Missing and 2 partials :warning: ](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #33      +/-   ##
==========================================
- Coverage   73.88%   70.67%   -3.22%     
==========================================
  Files           6        6              
  Lines         877      924      +47     
==========================================
+ Hits          648      653       +5     
- Misses        193      233      +40     
- Partials       36       38       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aksiksi/compose2nix/pull/33?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).


Patch:
diff --git a/.gitignore b/.gitignore
index b03ce28..96f03e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ out.nix*
 *.env
 result
 .pre-commit-config.yaml
+coverage.out
diff --git a/Makefile b/Makefile
index 00d311d..718bbe4 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@ test:
 	go test -v
 
 coverage:
-	go test -v -covermode=count
+	go test -v -covermode=count -coverprofile=coverage.out
 
 flake:
 	nix build -L .#packages.x86_64-linux.default
diff --git a/README.md b/README.md
index 430a41c..c230b3c 100644
--- a/README.md
+++ b/README.md
@@ -209,46 +209,48 @@ Discussion: https://github.com/aksiksi/compose2nix/issues/24
 ```
 $ compose2nix -h
 Usage of compose2nix:
+  -auto_format
+    	if true, Nix output will be formatted using "nixfmt" (must be present in $PATH).
   -auto_start
-        auto-start setting for generated service(s). this applies to all services, not just containers. (default true)
+    	auto-start setting for generated service(s). this applies to all services, not just containers. (default true)
   -check_systemd_mounts
-        if set, volume paths will be checked against systemd mount paths on the current machine and marked as container dependencies.
+    	if set, volume paths will be checked against systemd mount paths on the current machine and marked as container dependencies.
   -create_root_target
-        if set, a root systemd target will be created, which when stopped tears down all resources. (default true)
+    	if set, a root systemd target will be created, which when stopped tears down all resources. (default true)
   -default_stop_timeout duration
-        default stop timeout for generated container services. (default 1m30s)
+    	default stop timeout for generated container services. (default 1m30s)
   -env_files string
-        one or more comma-separated paths to .env file(s).
+    	one or more comma-separated paths to .env file(s).
   -env_files_only
-        only use env file(s) in the NixOS container definitions.
+    	only use env file(s) in the NixOS container definitions.
   -generate_unused_resources
-        if set, unused resources (e.g., networks) will be generated even if no containers use them.
+    	if set, unused resources (e.g., networks) will be generated even if no containers use them.
   -ignore_missing_env_files
-        if set, missing env files will be ignored.
+    	if set, missing env files will be ignored.
   -include_env_files
-        include env files in the NixOS container definition.
+    	include env files in the NixOS container definition.
   -inputs string
-        one or more comma-separated path(s) to Compose file(s). (default "docker-compose.yml")
+    	one or more comma-separated path(s) to Compose file(s). (default "docker-compose.yml")
   -output string
-        path to output Nix file. (default "docker-compose.nix")
+    	path to output Nix file. (default "docker-compose.nix")
   -project string
-        project name used as a prefix for generated resources. this overrides any top-level "name" set in the Compose file(s).
+    	project name used as a prefix for generated resources. this overrides any top-level "name" set in the Compose file(s).
   -remove_volumes
-        if set, volumes will be removed on systemd service stop.
+    	if set, volumes will be removed on systemd service stop.
   -root_path string
-        root path to use for any relative paths in the Compose file (e.g., volumes). if unset, the current working directory will be used.
+    	root path to use for any relative paths in the Compose file (e.g., volumes). if unset, the current working directory will be used.
   -runtime string
-        one of: ["podman", "docker"]. (default "podman")
+    	one of: ["podman", "docker"]. (default "podman")
   -service_include string
-        regex pattern for services to include.
+    	regex pattern for services to include.
   -use_compose_log_driver
-        if set, always use the Docker Compose log driver.
+    	if set, always use the Docker Compose log driver.
   -use_upheld_by
-        if set, upheldBy will be used for service dependencies (NixOS 24.05+).
+    	if set, upheldBy will be used for service dependencies (NixOS 24.05+).
   -version
-        display version and exit
+    	display version and exit
   -write_nix_setup
-        if true, Nix setup code is written to output (runtime, DNS, autoprune, etc.) (default true)
+    	if true, Nix setup code is written to output (runtime, DNS, autoprune, etc.) (default true)
 ```
 
 ### Supported Docker Compose Features
diff --git a/compose.go b/compose.go
index bb0f265..a6a8bed 100644
--- a/compose.go
+++ b/compose.go
@@ -80,6 +80,7 @@ type Generator struct {
 	UseUpheldBy             bool
 	RemoveVolumes           bool
 	NoCreateRootTarget      bool
+	AutoFormat              bool
 	WriteHeader             bool
 	NoWriteNixSetup         bool
 	DefaultStopTimeout      time.Duration
@@ -164,6 +165,7 @@ func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
 		CreateRootTarget: !g.NoCreateRootTarget,
 		AutoStart:        g.AutoStart,
 		WriteNixSetup:    !g.NoWriteNixSetup,
+		AutoFormat:       g.AutoFormat,
 	}, nil
 }
 
diff --git a/flake.nix b/flake.nix
index 90411be..5eda977 100644
--- a/flake.nix
+++ b/flake.nix
@@ -36,7 +36,7 @@
     devShells = forAllSystems (system:
       let pkgs = pkgsFor system; in {
         default = pkgs.mkShell {
-          buildInputs = [ pkgs.go pkgs.gopls ];
+          buildInputs = [ pkgs.go pkgs.gopls pkgs.nixfmt-rfc-style ];
           # Add a Git pre-commit hook.
           shellHook = onchg.shellHook.${system};
         };
diff --git a/helpers.go b/helpers.go
index 22e5c8d..2a9a725 100644
--- a/helpers.go
+++ b/helpers.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"log"
 	"os"
+	"os/exec"
 	"slices"
 	"strings"
 )
@@ -61,3 +62,37 @@ func ReadEnvFiles(envFiles []string, mergeWithEnv, ignoreMissing bool) (env []st
 
 	return env, nil
 }
+
+// formatNixCode will format Nix code by calling 'nixfmt' and passing in the
+// given code via stdin.
+func formatNixCode(contents []byte) ([]byte, error) {
+	// Check for existence of 'nixfmt' in $PATH.
+	nixfmtPath, err := exec.LookPath("nixfmt")
+	if err != nil {
+		return nil, fmt.Errorf("'nixfmt' not found in $PATH: %w", err)
+	}
+
+	cmd := exec.Command(nixfmtPath)
+
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		return nil, fmt.Errorf("failed to setup stdin pipe: %w", err)
+	}
+	if err := func() error {
+		defer stdin.Close()
+		if _, err := stdin.Write(contents); err != nil {
+			return fmt.Errorf("failed to write content to stdin: %w", err)
+		}
+		return nil
+	}(); err != nil {
+		return nil, err
+	}
+
+	// Overwrite contents with formatted output.
+	contents, err = cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("failed to run 'nixfmt' on contents: %w", err)
+	}
+
+	return contents, nil
+}
diff --git a/main.go b/main.go
index 406c63d..c850dc4 100644
--- a/main.go
+++ b/main.go
@@ -38,6 +38,7 @@ var removeVolumes = flag.Bool("remove_volumes", false, "if set, volumes will be
 var createRootTarget = flag.Bool("create_root_target", true, "if set, a root systemd target will be created, which when stopped tears down all resources.")
 var defaultStopTimeout = flag.Duration("default_stop_timeout", defaultSystemdStopTimeout, "default stop timeout for generated container services.")
 var writeNixSetup = flag.Bool("write_nix_setup", true, "if true, Nix setup code is written to output (runtime, DNS, autoprune, etc.)")
+var autoFormat = flag.Bool("auto_format", false, `if true, Nix output will be formatted using "nixfmt" (must be present in $PATH).`)
 var version = flag.Bool("version", false, "display version and exit")
 
 func main() {
@@ -47,6 +48,9 @@ func main() {
 		fmt.Printf("compose2nix v%s\n", appVersion)
 		return
 	}
+	if *output == "" {
+		log.Fatal("No output path specified.")
+	}
 
 	ctx := context.Background()
 
@@ -95,6 +99,7 @@ func main() {
 		NoCreateRootTarget:      !*createRootTarget,
 		WriteHeader:             true,
 		NoWriteNixSetup:         !*writeNixSetup,
+		AutoFormat:              *autoFormat,
 		DefaultStopTimeout:      *defaultStopTimeout,
 	}
 	containerConfig, err := g.Run(ctx)
@@ -103,14 +108,16 @@ func main() {
 	}
 	fmt.Printf("Generated NixOS config in %v\n", time.Since(start))
 
-	if *output != "" {
-		dir := path.Dir(*output)
-		if _, err := os.Stat(dir); err != nil {
-			log.Fatalf("Directory %q does not exist", dir)
-		}
-		if err := os.WriteFile(*output, []byte(containerConfig.String()), 0644); err != nil {
-			log.Fatal(err)
-		}
-		fmt.Printf("Wrote NixOS config to %s\n", *output)
+	dir := path.Dir(*output)
+	if _, err := os.Stat(dir); err != nil {
+		log.Fatalf("Directory %q does not exist: %v", dir, err)
+	}
+	f, err := os.Create(*output)
+	if err != nil {
+		log.Fatalf("Failed to create file %q: %v", *output, err)
+	}
+	if err := containerConfig.Write(f); err != nil {
+		log.Fatal(err)
 	}
+	fmt.Printf("Wrote NixOS config to %s\n", *output)
 }
diff --git a/nix.go b/nix.go
index a188edb..8dda548 100644
--- a/nix.go
+++ b/nix.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"fmt"
+	"io"
 	"strings"
 	"text/template"
 )
@@ -203,6 +204,7 @@ type NixContainerConfig struct {
 	Volumes          []*NixVolume
 	CreateRootTarget bool
 	WriteNixSetup    bool
+	AutoFormat       bool
 	AutoStart        bool
 }
 
@@ -221,6 +223,29 @@ func (c *NixContainerConfig) String() string {
 	return s.String()
 }
 
+// Write writes out the Nix config to the provided Writer.
+//
+// If the AutoFormat option on this struct is set to "true", this method will
+// attempt to format the Nix config by calling "nixfmt" and passing in the
+// fully built config via stdin.
+func (c *NixContainerConfig) Write(out io.Writer) error {
+	config := []byte(c.String())
+
+	if c.AutoFormat {
+		formatted, err := formatNixCode(config)
+		if err != nil {
+			return err
+		}
+		config = formatted
+	}
+
+	if _, err := out.Write(config); err != nil {
+		return fmt.Errorf("failed to write Nix code: %w", err)
+	}
+
+	return nil
+}
+
 func rootTarget(runtime ContainerRuntime, project *Project) string {
 	return fmt.Sprintf("%s-compose-%s", runtime, project.With("root"))
 }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 497fd3e..d0614b0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,9 +20,26 @@ jobs:
         go-version: '>=1.21'
     - run: go version
     - name: Test
-      run: go test -v -covermode=count -coverprofile=coverage.out
+      run: make coverage
     - name: Upload coverage
-      uses: codecov/codecov-action@v3
+      uses: codecov/codecov-action@v4
+      with:
+        fail_ci_if_error: true
+        files: coverage.out
+        token: ${{ secrets.CODECOV_TOKEN }}
+  linux-test-with-nixfmt:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+    - uses: cachix/install-nix-action@v22
+      with:
+        github_access_token: ${{ secrets.GITHUB_TOKEN }}
+        nix_path: nixpkgs=channel:nixos-unstable
+    - uses: DeterminateSystems/magic-nix-cache-action@main
+    - name: Run tests with nixfmt
+      run: nix develop --command make coverage
+    - name: Upload coverage
+      uses: codecov/codecov-action@v4
       with:
         fail_ci_if_error: true
         files: coverage.out
@@ -37,3 +54,4 @@ jobs:
         nix_path: nixpkgs=channel:nixos-unstable
     - uses: DeterminateSystems/magic-nix-cache-action@main
     - run: make flake
+
diff --git a/nix_test.go b/nix_test.go
index 21c1b79..9142b4f 100644
--- a/nix_test.go
+++ b/nix_test.go
@@ -1,10 +1,12 @@
 package main
 
 import (
+	"bytes"
 	"context"
 	"flag"
 	"fmt"
 	"os"
+	"os/exec"
 	"path"
 	"strings"
 	"testing"
@@ -39,7 +41,11 @@ func runSubtestsWithGenerator(t *testing.T, g *Generator) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			got := c.String()
+			gotBuf := new(bytes.Buffer)
+			if err := c.Write(gotBuf); err != nil {
+				t.Fatal(err)
+			}
+			got := gotBuf.String()
 			if *update {
 				if err := os.WriteFile(outFilePath, []byte(got), 0644); err != nil {
 					t.Fatal(err)
@@ -68,6 +74,21 @@ func TestBasic(t *testing.T) {
 	runSubtestsWithGenerator(t, g)
 }
 
+func TestBasicAutoFormat(t *testing.T) {
+	if _, err := exec.LookPath("nixfmt"); err != nil {
+		t.Skip()
+	}
+	composePath, envFilePath := getPaths(t, true)
+	g := &Generator{
+		Inputs:                  []string{composePath},
+		EnvFiles:                []string{envFilePath},
+		AutoStart:               true,
+		GenerateUnusedResources: true,
+		AutoFormat:              true,
+	}
+	runSubtestsWithGenerator(t, g)
+}
+
 func TestProject(t *testing.T) {
 	composePath, envFilePath := getPaths(t, true)
 	g := &Generator{
diff --git a/testdata/TestBasicAutoFormat.docker.nix b/testdata/TestBasicAutoFormat.docker.nix
new file mode 100644
index 0000000..63d24da
--- /dev/null
+++ b/testdata/TestBasicAutoFormat.docker.nix
@@ -0,0 +1,351 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.docker = {
+    enable = true;
+    autoPrune.enable = true;
+  };
+  virtualisation.oci-containers.backend = "docker";
+
+  # Containers
+  virtualisation.oci-containers.containers."jellyseerr" = {
+    image = "docker.io/fallenbagel/jellyseerr:latest";
+    environment = {
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/var/volumes/jellyseerr:/app/config:rw"
+      "myproject_books:/books:rw"
+    ];
+    cmd = [
+      "ls"
+      "-la"
+      "/"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.jellyseerr.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.jellyseerr.rule" = "Host(`requests.hello.us`)";
+      "traefik.http.routers.jellyseerr.tls.certresolver" = "htpc";
+    };
+    dependsOn = [ "myproject-sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--cpu-quota=1.5"
+      "--cpus=1"
+      "--dns=1.1.1.1"
+      "--health-cmd=curl -f http://localhost/\${POTATO}"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--memory-reservation=524288000b"
+      "--memory=1048576000b"
+      "--network=container:myproject-sabnzbd"
+    ];
+  };
+  systemd.services."docker-jellyseerr" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "on-failure";
+      RestartMaxDelaySec = lib.mkOverride 500 "1m";
+      RestartSec = lib.mkOverride 500 "100ms";
+      RestartSteps = lib.mkOverride 500 9;
+    };
+    startLimitBurst = 3;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 120;
+    };
+    after = [ "docker-volume-myproject_books.service" ];
+    requires = [ "docker-volume-myproject_books.service" ];
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."myproject-sabnzbd" = {
+    image = "lscr.io/linuxserver/sabnzbd";
+    environment = {
+      "DOCKER_MODS" = "ghcr.io/gilbn/theme.park:sabnzbd";
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TP_DOMAIN" = "hey.hello.us\/themepark";
+      "TP_HOTIO" = "false";
+      "TP_THEME" = "potato";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/var/volumes/sabnzbd:/config:rw"
+      "storage:/storage:rw"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.sabnzbd.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.sabnzbd.rule" = "Host(`hey.hello.us`) && PathPrefix(`/sabnzbd`)";
+      "traefik.http.routers.sabnzbd.tls.certresolver" = "htpc";
+    };
+    log-driver = "journald";
+    extraOptions = [
+      "--health-cmd=curl -f http://localhost/"
+      "--hostname=sabnzbd"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network-alias=sabnzbd"
+      "--network=myproject_default"
+    ];
+  };
+  systemd.services."docker-myproject-sabnzbd" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+      RestartMaxDelaySec = lib.mkOverride 500 "1m";
+      RestartSec = lib.mkOverride 500 "100ms";
+      RestartSteps = lib.mkOverride 500 9;
+      RuntimeMaxSec = lib.mkOverride 500 10;
+    };
+    unitConfig = {
+      Description = lib.mkOverride 500 "This is the sabnzbd container!";
+    };
+    after = [
+      "docker-network-myproject_default.service"
+      "docker-volume-storage.service"
+    ];
+    requires = [
+      "docker-network-myproject_default.service"
+      "docker-volume-storage.service"
+    ];
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."photoprism-mariadb" = {
+    image = "docker.io/library/mariadb:10.9";
+    environment = {
+      "MARIADB_AUTO_UPGRADE" = "1";
+      "MARIADB_DATABASE" = "photoprism";
+      "MARIADB_INITDB_SKIP_TZINFO" = "1";
+      "MARIADB_PASSWORD" = "insecure";
+      "MARIADB_ROOT_PASSWORD" = "insecure";
+      "MARIADB_USER" = "photoprism";
+    };
+    volumes = [
+      "/var/volumes/photoprism-mariadb:/var/lib/mysql:rw"
+      "photos:/photos:rw"
+    ];
+    user = "1000:1000";
+    log-driver = "journald";
+    extraOptions = [
+      "--health-cmd=[\"curl\",\"-f\",\"http://localhost\"]"
+      "--health-interval=1m30s"
+      "--health-retries=3"
+      "--health-start-period=40s"
+      "--health-timeout=10s"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network=host"
+    ];
+  };
+  systemd.services."docker-photoprism-mariadb" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+      RestartMaxDelaySec = lib.mkOverride 500 "1m";
+      RestartSec = lib.mkOverride 500 "100ms";
+      RestartSteps = lib.mkOverride 500 9;
+    };
+    startLimitBurst = 10;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 "infinity";
+    };
+    after = [ "docker-volume-photos.service" ];
+    requires = [ "docker-volume-photos.service" ];
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."torrent-client" = {
+    image = "docker.io/haugene/transmission-openvpn";
+    environment = {
+      "GLOBAL_APPLY_PERMISSIONS" = "false";
+      "LOCAL_NETWORK" = "192.168.0.0/16";
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TRANSMISSION_DHT_ENABLED" = "false";
+      "TRANSMISSION_DOWNLOAD_DIR" = "/storage/Downloads/transmission";
+      "TRANSMISSION_HOME" = "/config/transmission-home";
+      "TRANSMISSION_INCOMPLETE_DIR" = "/storage/Downloads/transmission/incomplete";
+      "TRANSMISSION_INCOMPLETE_DIR_ENABLED" = "true";
+      "TRANSMISSION_PEX_ENABLED" = "false";
+      "TRANSMISSION_SCRIPT_TORRENT_DONE_ENABLED" = "true";
+      "TRANSMISSION_SCRIPT_TORRENT_DONE_FILENAME" = "/config/transmission-unpack.sh";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/etc/localtime:/etc/localtime:ro"
+      "/var/volumes/transmission/config:/config:rw"
+      "/var/volumes/transmission/scripts:/scripts:rw"
+      "storage:/storage:rw"
+    ];
+    ports = [ "9091:9091/tcp" ];
+    labels = {
+      "autoheal" = "true";
+      "traefik.enable" = "true";
+      "traefik.http.routers.transmission.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.transmission.rule" = "Host(`hey.hello.us`) && PathPrefix(`/transmission`)";
+      "traefik.http.routers.transmission.tls.certresolver" = "htpc";
+      "traefik.http.services.transmission.loadbalancer.server.port" = "9091";
+    };
+    dependsOn = [ "myproject-sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--add-host=abc:93.184.216.34"
+      "--add-host=abc:::1"
+      "--cap-add=NET_ADMIN"
+      "--device=/dev/net/tun:/dev/net/tun"
+      "--dns=8.8.4.4"
+      "--dns=8.8.8.8"
+      "--network-alias=my-torrent-client"
+      "--network-alias=transmission"
+      "--network=myproject_something"
+      "--no-healthcheck"
+      "--privileged"
+      "--shm-size=67108864"
+      "--sysctl=net.ipv6.conf.all.disable_ipv6=0"
+    ];
+  };
+  systemd.services."docker-torrent-client" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "on-failure";
+    };
+    startLimitBurst = 3;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 "infinity";
+    };
+    after = [
+      "docker-network-myproject_something.service"
+      "docker-volume-storage.service"
+    ];
+    requires = [
+      "docker-network-myproject_something.service"
+      "docker-volume-storage.service"
+    ];
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."traefik" = {
+    image = "docker.io/library/traefik";
+    environment = {
+      "CLOUDFLARE_API_KEY" = "yomama";
+      "CLOUDFLARE_EMAIL" = "aaa@aaa.com";
+    };
+    volumes = [
+      "/var/run/podman/podman.sock:/var/run/docker.sock:ro"
+      "/var/volumes/traefik:/etc/traefik:rw"
+    ];
+    ports = [
+      "80:80/tcp"
+      "443:443/tcp"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.traefik.entrypoints" = "https";
+      "traefik.http.routers.traefik.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.traefik.rule" = "Host(`hey.hello.us`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))";
+      "traefik.http.routers.traefik.service" = "api@internal";
+      "traefik.http.routers.traefik.tls.certresolver" = "htpc";
+    };
+    dependsOn = [ "sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network=container:sabnzbd"
+      "--runtime=nvidia"
+      "--security-opt=label=disable"
+    ];
+  };
+  systemd.services."docker-traefik" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "no";
+    };
+    unitConfig = {
+      AllowIsolate = lib.mkOverride 500 true;
+    };
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+
+  # Networks
+  systemd.services."docker-network-myproject_default" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "docker network rm -f myproject_default";
+    };
+    script = ''
+      docker network inspect myproject_default || docker network create myproject_default
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  systemd.services."docker-network-myproject_something" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "docker network rm -f myproject_something";
+    };
+    script = ''
+      docker network inspect myproject_something || docker network create myproject_something --label=test-label=okay
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."docker-volume-myproject_books" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect myproject_books || docker volume create myproject_books --opt=device=/mnt/media/Books --opt=o=bind --opt=type=none
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  systemd.services."docker-volume-photos" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect photos || docker volume create photos --opt=device=/mnt/photos --opt=o=bind --opt=type=none --label=test-label=okay
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  systemd.services."docker-volume-storage" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect storage || docker volume create storage --opt=device=/mnt/media --opt=o=bind --opt=type=none
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+
+  # Root service
+  # When started, this will automatically create all resources and start
+  # the containers. When stopped, this will teardown all resources.
+  systemd.targets."docker-compose-myproject-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+    wantedBy = [ "multi-user.target" ];
+  };
+}
diff --git a/testdata/TestBasicAutoFormat.podman.nix b/testdata/TestBasicAutoFormat.podman.nix
new file mode 100644
index 0000000..353691a
--- /dev/null
+++ b/testdata/TestBasicAutoFormat.podman.nix
@@ -0,0 +1,349 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.podman = {
+    enable = true;
+    autoPrune.enable = true;
+    dockerCompat = true;
+    defaultNetwork.settings = {
+      # Required for container networking to be able to use names.
+      dns_enabled = true;
+    };
+  };
+  virtualisation.oci-containers.backend = "podman";
+
+  # Containers
+  virtualisation.oci-containers.containers."jellyseerr" = {
+    image = "docker.io/fallenbagel/jellyseerr:latest";
+    environment = {
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/var/volumes/jellyseerr:/app/config:rw"
+      "myproject_books:/books:rw"
+    ];
+    cmd = [
+      "ls"
+      "-la"
+      "/"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.jellyseerr.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.jellyseerr.rule" = "Host(`requests.hello.us`)";
+      "traefik.http.routers.jellyseerr.tls.certresolver" = "htpc";
+    };
+    dependsOn = [ "myproject-sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--cpu-quota=1.5"
+      "--cpus=1"
+      "--dns=1.1.1.1"
+      "--health-cmd=curl -f http://localhost/\${POTATO}"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--memory-reservation=524288000b"
+      "--memory=1048576000b"
+      "--network=container:myproject-sabnzbd"
+    ];
+  };
+  systemd.services."podman-jellyseerr" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "on-failure";
+      RestartSec = lib.mkOverride 500 "5s";
+    };
+    startLimitBurst = 3;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 120;
+    };
+    after = [ "podman-volume-myproject_books.service" ];
+    requires = [ "podman-volume-myproject_books.service" ];
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."myproject-sabnzbd" = {
+    image = "lscr.io/linuxserver/sabnzbd";
+    environment = {
+      "DOCKER_MODS" = "ghcr.io/gilbn/theme.park:sabnzbd";
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TP_DOMAIN" = "hey.hello.us\/themepark";
+      "TP_HOTIO" = "false";
+      "TP_THEME" = "potato";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/var/volumes/sabnzbd:/config:rw"
+      "storage:/storage:rw"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.sabnzbd.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.sabnzbd.rule" = "Host(`hey.hello.us`) && PathPrefix(`/sabnzbd`)";
+      "traefik.http.routers.sabnzbd.tls.certresolver" = "htpc";
+    };
+    log-driver = "journald";
+    extraOptions = [
+      "--health-cmd=curl -f http://localhost/"
+      "--hostname=sabnzbd"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network-alias=sabnzbd"
+      "--network=myproject_default"
+    ];
+  };
+  systemd.services."podman-myproject-sabnzbd" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+      RuntimeMaxSec = lib.mkOverride 500 10;
+    };
+    unitConfig = {
+      Description = lib.mkOverride 500 "This is the sabnzbd container!";
+    };
+    after = [
+      "podman-network-myproject_default.service"
+      "podman-volume-storage.service"
+    ];
+    requires = [
+      "podman-network-myproject_default.service"
+      "podman-volume-storage.service"
+    ];
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."photoprism-mariadb" = {
+    image = "docker.io/library/mariadb:10.9";
+    environment = {
+      "MARIADB_AUTO_UPGRADE" = "1";
+      "MARIADB_DATABASE" = "photoprism";
+      "MARIADB_INITDB_SKIP_TZINFO" = "1";
+      "MARIADB_PASSWORD" = "insecure";
+      "MARIADB_ROOT_PASSWORD" = "insecure";
+      "MARIADB_USER" = "photoprism";
+    };
+    volumes = [
+      "/var/volumes/photoprism-mariadb:/var/lib/mysql:rw"
+      "photos:/photos:rw"
+    ];
+    user = "1000:1000";
+    log-driver = "journald";
+    extraOptions = [
+      "--health-cmd=[\"curl\",\"-f\",\"http://localhost\"]"
+      "--health-interval=1m30s"
+      "--health-retries=3"
+      "--health-start-interval=5s"
+      "--health-start-period=40s"
+      "--health-timeout=10s"
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network=host"
+    ];
+  };
+  systemd.services."podman-photoprism-mariadb" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+      RestartSec = lib.mkOverride 500 "3m0s";
+    };
+    startLimitBurst = 10;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 "infinity";
+    };
+    after = [ "podman-volume-photos.service" ];
+    requires = [ "podman-volume-photos.service" ];
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."torrent-client" = {
+    image = "docker.io/haugene/transmission-openvpn";
+    environment = {
+      "GLOBAL_APPLY_PERMISSIONS" = "false";
+      "LOCAL_NETWORK" = "192.168.0.0/16";
+      "PGID" = "1000";
+      "PUID" = "1000";
+      "TRANSMISSION_DHT_ENABLED" = "false";
+      "TRANSMISSION_DOWNLOAD_DIR" = "/storage/Downloads/transmission";
+      "TRANSMISSION_HOME" = "/config/transmission-home";
+      "TRANSMISSION_INCOMPLETE_DIR" = "/storage/Downloads/transmission/incomplete";
+      "TRANSMISSION_INCOMPLETE_DIR_ENABLED" = "true";
+      "TRANSMISSION_PEX_ENABLED" = "false";
+      "TRANSMISSION_SCRIPT_TORRENT_DONE_ENABLED" = "true";
+      "TRANSMISSION_SCRIPT_TORRENT_DONE_FILENAME" = "/config/transmission-unpack.sh";
+      "TZ" = "America/New_York";
+    };
+    volumes = [
+      "/etc/localtime:/etc/localtime:ro"
+      "/var/volumes/transmission/config:/config:rw"
+      "/var/volumes/transmission/scripts:/scripts:rw"
+      "storage:/storage:rw"
+    ];
+    ports = [ "9091:9091/tcp" ];
+    labels = {
+      "autoheal" = "true";
+      "traefik.enable" = "true";
+      "traefik.http.routers.transmission.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.transmission.rule" = "Host(`hey.hello.us`) && PathPrefix(`/transmission`)";
+      "traefik.http.routers.transmission.tls.certresolver" = "htpc";
+      "traefik.http.services.transmission.loadbalancer.server.port" = "9091";
+    };
+    dependsOn = [ "myproject-sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--add-host=abc:93.184.216.34"
+      "--add-host=abc:::1"
+      "--cap-add=NET_ADMIN"
+      "--device=/dev/net/tun:/dev/net/tun"
+      "--dns=8.8.4.4"
+      "--dns=8.8.8.8"
+      "--network-alias=transmission"
+      "--network=myproject_something:alias=my-torrent-client"
+      "--no-healthcheck"
+      "--privileged"
+      "--shm-size=67108864"
+      "--sysctl=net.ipv6.conf.all.disable_ipv6=0"
+    ];
+  };
+  systemd.services."podman-torrent-client" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "on-failure";
+    };
+    startLimitBurst = 3;
+    unitConfig = {
+      StartLimitIntervalSec = lib.mkOverride 500 "infinity";
+    };
+    after = [
+      "podman-network-myproject_something.service"
+      "podman-volume-storage.service"
+    ];
+    requires = [
+      "podman-network-myproject_something.service"
+      "podman-volume-storage.service"
+    ];
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  virtualisation.oci-containers.containers."traefik" = {
+    image = "docker.io/library/traefik";
+    environment = {
+      "CLOUDFLARE_API_KEY" = "yomama";
+      "CLOUDFLARE_EMAIL" = "aaa@aaa.com";
+    };
+    volumes = [
+      "/var/run/podman/podman.sock:/var/run/docker.sock:ro"
+      "/var/volumes/traefik:/etc/traefik:rw"
+    ];
+    ports = [
+      "80:80/tcp"
+      "443:443/tcp"
+    ];
+    labels = {
+      "traefik.enable" = "true";
+      "traefik.http.routers.traefik.entrypoints" = "https";
+      "traefik.http.routers.traefik.middlewares" = "chain-authelia@file";
+      "traefik.http.routers.traefik.rule" = "Host(`hey.hello.us`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))";
+      "traefik.http.routers.traefik.service" = "api@internal";
+      "traefik.http.routers.traefik.tls.certresolver" = "htpc";
+    };
+    dependsOn = [ "sabnzbd" ];
+    log-driver = "journald";
+    extraOptions = [
+      "--log-opt=compress=true"
+      "--log-opt=max-file=3"
+      "--log-opt=max-size=10m"
+      "--network=container:sabnzbd"
+      "--runtime=nvidia"
+      "--security-opt=label=disable"
+    ];
+  };
+  systemd.services."podman-traefik" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "no";
+    };
+    unitConfig = {
+      AllowIsolate = lib.mkOverride 500 true;
+    };
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+
+  # Networks
+  systemd.services."podman-network-myproject_default" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "podman network rm -f myproject_default";
+    };
+    script = ''
+      podman network inspect myproject_default || podman network create myproject_default
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  systemd.services."podman-network-myproject_something" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "podman network rm -f myproject_something";
+    };
+    script = ''
+      podman network inspect myproject_something || podman network create myproject_something --label=test-label=okay
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."podman-volume-myproject_books" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect myproject_books || podman volume create myproject_books --opt=device=/mnt/media/Books --opt=o=bind --opt=type=none
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  systemd.services."podman-volume-photos" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect photos || podman volume create photos --opt=device=/mnt/photos --opt=o=bind --opt=type=none --label=test-label=okay
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  systemd.services."podman-volume-storage" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect storage || podman volume create storage --opt=device=/mnt/media --opt=o=bind --opt=type=none
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+
+  # Root service
+  # When started, this will automatically create all resources and start
+  # the containers. When stopped, this will teardown all resources.
+  systemd.targets."podman-compose-myproject-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+    wantedBy = [ "multi-user.target" ];
+  };
+}