Skip to main content

dev_prune/adapters/
terraform.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Terraform adapter.
5//
6// `.terraform/providers/` only, and deliberately not `.terraform/` itself. Three things
7// live in that directory that a reinstall does not bring back the same:
8//
9// - `.terraform/environment` records the selected workspace. Delete it and Terraform
10//   silently falls back to `default` — so the next `terraform apply` an operator runs
11//   without looking targets the wrong environment. Nothing about that is recoverable in
12//   the sense this tool means, and the failure is production, not a slow rebuild.
13// - `.terraform/terraform.tfstate` is the backend's initialisation record. Rebuilding it
14//   needs `terraform init` with the backend's credentials, which a prune has no business
15//   assuming are present.
16// - `.terraform/modules/` is fetched from module sources, and `.terraform.lock.hcl` does
17//   not cover modules — only providers. An unpinned `git::` module source resolves to
18//   whatever that branch says today, so deleting the directory can change what comes
19//   back. That is exactly the thing this tool refuses to do.
20//
21// Providers are the bulk anyway — a handful of them is hundreds of megabytes of
22// statically linked plugin binaries, per root module, and a repository with ten
23// environments has ten copies.
24//
25// No manifest-staleness check, unlike Mix and CocoaPods: the nearest thing to a manifest
26// is every `.tf` file in the directory, and those are edited constantly for reasons that
27// have nothing to do with provider requirements. A lock file older than a `.tf` is the
28// normal state of a healthy Terraform project, so refusing on it would refuse almost
29// always — and a check that always fires teaches people to bypass it.
30
31use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command_with_timeout};
32use anyhow::{Result, anyhow};
33use std::fs;
34use std::path::Path;
35
36/// Terraform adapter.
37pub struct Terraform;
38
39impl PackageManager for Terraform {
40    fn name(&self) -> &'static str {
41        "terraform"
42    }
43
44    fn detect(&self, path: &Path) -> bool {
45        // A root module is a directory with `.tf` files in it; there is no fixed manifest
46        // filename to look for. `.tf.json` counts — it is the same language, and
47        // generators emit it.
48        let Ok(entries) = fs::read_dir(path) else {
49            return false;
50        };
51        entries.filter_map(Result::ok).any(|entry| {
52            let name = entry.file_name();
53            let Some(name) = name.to_str() else {
54                return false;
55            };
56            name.ends_with(".tf") || name.ends_with(".tf.json")
57        })
58    }
59
60    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
61        let providers = path.join(".terraform").join("providers");
62        if !providers.is_dir() {
63            return Vec::new();
64        }
65        vec![BloatDir {
66            name: ".terraform/providers".to_string(),
67            path: providers.clone(),
68            size_bytes: dir_size(&providers),
69            shared_bytes: 0,
70        }]
71    }
72
73    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
74        let lock = path.join(".terraform.lock.hcl");
75        let content = fs::read_to_string(&lock).map_err(|e| {
76            anyhow!(
77                "`.terraform.lock.hcl` could not be read ({e}) — without it `terraform \
78                 init` selects provider versions afresh instead of restoring the ones \
79                 being deleted."
80            )
81        })?;
82        // Every entry is a `provider "registry.terraform.io/..." { ... }` block. A file
83        // with none is a lock file Terraform wrote before any provider was required, and
84        // it proves nothing about the plugins on disk.
85        if !content.contains("provider \"") {
86            return Err(anyhow!(
87                "`.terraform.lock.hcl` records no providers — it cannot prove \
88                 `.terraform/providers` is rebuildable. Run `terraform init` and prune \
89                 again."
90            ));
91        }
92        Ok(())
93    }
94
95    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
96        // `-backend=false` because reinstalling plugins must not need the backend's
97        // credentials. Only the providers were deleted, so only the providers are what
98        // this has to put back; touching the backend would turn a local restore into a
99        // request against somebody's state bucket.
100        run_command_with_timeout("terraform", &["init", "-backend=false"], path, timeout)
101    }
102
103    fn lockfiles(&self) -> &'static [&'static str] {
104        &[".terraform.lock.hcl"]
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use tempfile::tempdir;
112
113    const LOCK: &str =
114        "provider \"registry.terraform.io/hashicorp/aws\" {\n  version = \"5.0.0\"\n}\n";
115
116    #[test]
117    fn detects_on_any_terraform_source_file() {
118        let dir = tempdir().unwrap();
119        assert!(!Terraform.detect(dir.path()));
120        fs::write(dir.path().join("README.md"), "not terraform").unwrap();
121        assert!(!Terraform.detect(dir.path()));
122        fs::write(
123            dir.path().join("main.tf"),
124            "resource \"null_resource\" \"a\" {}",
125        )
126        .unwrap();
127        assert!(Terraform.detect(dir.path()));
128    }
129
130    #[test]
131    fn detects_generated_json_configuration_too() {
132        let dir = tempdir().unwrap();
133        fs::write(dir.path().join("main.tf.json"), "{}").unwrap();
134        assert!(Terraform.detect(dir.path()));
135    }
136
137    #[test]
138    fn claims_the_provider_cache_and_nothing_else_under_dot_terraform() {
139        let dir = tempdir().unwrap();
140        let dot = dir.path().join(".terraform");
141        fs::create_dir_all(dot.join("providers")).unwrap();
142        fs::create_dir_all(dot.join("modules")).unwrap();
143        fs::write(dot.join("environment"), "production").unwrap();
144        fs::write(dot.join("terraform.tfstate"), "{}").unwrap();
145
146        let dirs = Terraform.bloat_dirs(dir.path());
147        assert_eq!(dirs.len(), 1);
148        assert_eq!(dirs[0].name, ".terraform/providers");
149        assert_eq!(dirs[0].path, dot.join("providers"));
150    }
151
152    #[test]
153    fn an_uninitialised_project_offers_nothing_to_prune() {
154        let dir = tempdir().unwrap();
155        fs::write(dir.path().join("main.tf"), "").unwrap();
156        assert!(Terraform.bloat_dirs(dir.path()).is_empty());
157    }
158
159    #[test]
160    fn a_missing_or_providerless_lockfile_is_refused() {
161        let dir = tempdir().unwrap();
162        assert!(
163            Terraform
164                .enforce_lockfile(dir.path(), EnforcePolicy::default())
165                .is_err()
166        );
167        fs::write(
168            dir.path().join(".terraform.lock.hcl"),
169            "# This file is maintained automatically by \"terraform init\".\n",
170        )
171        .unwrap();
172        assert!(
173            Terraform
174                .enforce_lockfile(dir.path(), EnforcePolicy::default())
175                .is_err()
176        );
177        fs::write(dir.path().join(".terraform.lock.hcl"), LOCK).unwrap();
178        assert!(
179            Terraform
180                .enforce_lockfile(dir.path(), EnforcePolicy::default())
181                .is_ok()
182        );
183    }
184}