use crate::config::{self, Config};
use crate::shared::logging;
use crate::terraform::model::core::TerraformAnalysis;
use crate::terraform::model::graph::ResourceDependencyGraph;
use crate::terraform::model::health::ModuleHealthAnalysis;
use crate::terraform::model::refactoring::RefactoringSuggestion;
use crate::terraform::model::validation::{DetailedValidationResult, GuidelineCheckResult};
use crate::terraform::project::{TerraformEntrypoint, TerraformProjectInspection};
use crate::terraform::quality::{QualitySeverity, TerraformQualityCheck, TerraformQualityReport};
use crate::terraform::service::TerraformService;
use crate::terraform::state_safety::{
DriftCandidates, StateSafetyInspection, TerraformChangePreparation,
};
use std::path::{Path, PathBuf};
const SAMPLE_TERRAFORM_CONTENT: &str = r#"# This is a sample Terraform file created by tfmcp
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.0"
}
}
}
resource "local_file" "example" {
content = "Hello from tfmcp!"
filename = "${path.module}/example.txt"
}
"#;
fn create_sample_terraform_file(dir: &Path) -> std::io::Result<()> {
let main_tf_path = dir.join("main.tf");
if !main_tf_path.exists() {
logging::info(&format!(
"Creating sample Terraform file at: {}",
main_tf_path.display()
));
std::fs::write(&main_tf_path, SAMPLE_TERRAFORM_CONTENT)?;
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum TfMcpError {
#[error("Terraform binary not found")]
TerraformNotFound,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub struct TfMcp {
#[allow(dead_code)]
config: Config,
terraform_service: TerraformService,
}
impl TfMcp {
pub fn new(config_path: Option<String>, project_dir: Option<String>) -> anyhow::Result<Self> {
let env_terraform_dir = std::env::var("TERRAFORM_DIR").ok();
if let Some(dir) = &env_terraform_dir {
logging::info(&format!("Found TERRAFORM_DIR environment variable: {dir}"));
}
let config = match config_path {
Some(path) => {
let path_buf = PathBuf::from(&path);
if path_buf.is_absolute() {
logging::info(&format!("Using absolute config path: {path}"));
config::init_from_path(&path)?
} else {
let abs_path = std::env::current_dir()?.join(&path);
logging::info(&format!(
"Converting relative config path to absolute: {}",
abs_path.display()
));
config::init_from_path(abs_path.to_str().unwrap_or(&path))?
}
}
None => {
logging::info("No config path provided, using default configuration");
config::init_default()?
}
};
let project_directory = match project_dir {
Some(dir) => {
let dir_buf = PathBuf::from(&dir);
if dir_buf.is_absolute() {
logging::info(&format!(
"Using absolute project directory from CLI arg: {dir}"
));
dir_buf
} else {
let abs_dir = std::env::current_dir()?.join(dir);
logging::info(&format!(
"Converting relative project directory from CLI to absolute: {}",
abs_dir.display()
));
abs_dir
}
}
None => {
match env_terraform_dir {
Some(dir) => {
logging::info(&format!(
"Using project directory from TERRAFORM_DIR env var: {dir}"
));
PathBuf::from(dir)
}
None => {
match &config.terraform.project_directory {
Some(dir) => {
let dir_buf = PathBuf::from(dir);
if dir_buf.is_absolute() {
logging::info(&format!(
"Using project directory from config: {dir}"
));
dir_buf
} else {
let abs_dir = std::env::current_dir()?.join(dir);
logging::info(&format!(
"Converting relative project directory from config to absolute: {}",
abs_dir.display()
));
abs_dir
}
}
None => {
let current_dir = std::env::current_dir()?;
if current_dir == Path::new("/") {
let home_dir = dirs::home_dir().unwrap_or(current_dir.clone());
let tf_dir = home_dir.join("terraform");
logging::info(&format!(
"Working directory is root (/), falling back to home directory: {}",
tf_dir.display()
));
tf_dir
} else {
logging::info(&format!(
"No project directory specified, using current directory: {}",
current_dir.display()
));
current_dir
}
}
}
}
}
}
};
let terraform_path = match &config.terraform.executable_path {
Some(path) => {
let path_buf = PathBuf::from(path);
if path_buf.is_absolute() {
logging::info(&format!("Using specified Terraform binary: {path}"));
path_buf
} else {
let abs_path = std::env::current_dir()?.join(path);
logging::info(&format!(
"Converting relative Terraform path to absolute: {}",
abs_path.display()
));
abs_path
}
}
None => {
let terraform_binary = std::env::var("TERRAFORM_BINARY_NAME")
.unwrap_or_else(|_| "terraform".to_string());
match which::which(&terraform_binary) {
Ok(path) => {
logging::info(&format!(
"Found Terraform binary '{}' in PATH: {}",
terraform_binary,
path.display()
));
path
}
Err(_) => {
logging::error(&format!(
"Terraform binary '{terraform_binary}' not found in PATH"
));
return Err(TfMcpError::TerraformNotFound.into());
}
}
}
};
if !terraform_path.exists() {
logging::error(&format!(
"Terraform binary not found at: {}",
terraform_path.display()
));
return Err(TfMcpError::TerraformNotFound.into());
}
let has_tf_files = std::fs::read_dir(&project_directory)
.map(|entries| {
entries
.filter_map(Result::ok)
.any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf"))
})
.unwrap_or(false);
if !has_tf_files {
logging::info(&format!(
"No Terraform (.tf) files found in {}. Creating a sample project.",
project_directory.display()
));
if !project_directory.exists() {
logging::info(&format!(
"Creating directory: {}",
project_directory.display()
));
std::fs::create_dir_all(&project_directory)?;
}
create_sample_terraform_file(&project_directory)?;
}
let terraform_service = TerraformService::new(terraform_path, project_directory);
logging::info("TfMcp initialized successfully");
Ok(Self {
config,
terraform_service,
})
}
pub async fn analyze_terraform(&mut self) -> anyhow::Result<()> {
let analysis = self.terraform_service.analyze_configurations().await?;
println!("{}", serde_json::to_string_pretty(&analysis)?);
Ok(())
}
#[allow(dead_code)]
pub async fn get_terraform_analysis(&self) -> anyhow::Result<TerraformAnalysis> {
self.terraform_service.analyze_configurations().await
}
pub async fn inspect_project(&self) -> anyhow::Result<TerraformProjectInspection> {
crate::terraform::project::inspect_project(&self.get_project_directory())
}
pub async fn detect_entrypoints(&self) -> anyhow::Result<Vec<TerraformEntrypoint>> {
crate::terraform::project::detect_entrypoints(&self.get_project_directory())
}
#[allow(dead_code)]
pub async fn get_terraform_version(&self) -> anyhow::Result<String> {
self.terraform_service.get_version().await
}
pub async fn get_terraform_plan(&self) -> anyhow::Result<String> {
self.terraform_service.get_plan().await
}
pub async fn apply_terraform(&self, auto_approve: bool) -> anyhow::Result<String> {
self.terraform_service.apply(auto_approve).await
}
pub async fn init_terraform(&self) -> anyhow::Result<String> {
self.terraform_service.init().await
}
pub async fn get_state(&self) -> anyhow::Result<String> {
self.terraform_service.get_state().await
}
pub async fn list_resources(&self) -> anyhow::Result<Vec<String>> {
self.terraform_service.list_resources().await
}
pub async fn validate_configuration(&self) -> anyhow::Result<String> {
self.terraform_service.validate().await
}
pub async fn validate_configuration_detailed(
&self,
) -> anyhow::Result<DetailedValidationResult> {
self.terraform_service.validate_detailed().await
}
pub async fn destroy_terraform(&self, auto_approve: bool) -> anyhow::Result<String> {
let delete_enabled = std::env::var("TFMCP_DELETE_ENABLED")
.map(|val| val.to_lowercase() == "true")
.unwrap_or(false);
if !delete_enabled {
return Err(anyhow::anyhow!(
"Delete functionality is disabled. Set TFMCP_DELETE_ENABLED=true to enable it."
));
}
logging::info("Executing Terraform destroy operation");
self.terraform_service.destroy(auto_approve).await
}
pub fn change_project_directory(&mut self, new_directory: String) -> anyhow::Result<()> {
let dir_path = PathBuf::from(new_directory);
let project_directory = if dir_path.is_absolute() {
logging::info(&format!(
"Changing to absolute project directory: {}",
dir_path.display()
));
dir_path
} else {
let abs_dir = std::env::current_dir()?.join(dir_path);
logging::info(&format!(
"Converting relative project directory to absolute: {}",
abs_dir.display()
));
abs_dir
};
if !project_directory.exists() {
logging::info(&format!(
"Creating directory: {}",
project_directory.display()
));
std::fs::create_dir_all(&project_directory)?;
}
let has_tf_files = std::fs::read_dir(&project_directory)
.map(|entries| {
entries
.filter_map(Result::ok)
.any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf"))
})
.unwrap_or(false);
if !has_tf_files {
logging::info(&format!(
"No Terraform (.tf) files found in {}. Creating a sample project.",
project_directory.display()
));
create_sample_terraform_file(&project_directory)?;
}
match self
.terraform_service
.change_project_directory(project_directory.clone())
{
Ok(_) => {
unsafe {
std::env::set_var(
"TERRAFORM_DIR",
project_directory.to_string_lossy().to_string(),
);
}
logging::info(&format!(
"Successfully changed project directory to: {}",
project_directory.display()
));
Ok(())
}
Err(e) => {
logging::error(&format!("Failed to change project directory: {e}"));
Err(e)
}
}
}
pub fn get_project_directory(&self) -> PathBuf {
self.terraform_service.get_project_directory().clone()
}
pub async fn analyze_module_health(&self) -> anyhow::Result<ModuleHealthAnalysis> {
self.terraform_service.analyze_module_health().await
}
pub async fn get_dependency_graph(&self) -> anyhow::Result<ResourceDependencyGraph> {
self.terraform_service.get_dependency_graph().await
}
pub async fn suggest_refactoring(&self) -> anyhow::Result<Vec<RefactoringSuggestion>> {
self.terraform_service.suggest_refactoring().await
}
pub async fn run_security_scan(&self) -> anyhow::Result<GuidelineCheckResult> {
self.terraform_service.run_security_scan().await
}
pub async fn analyze_plan(
&self,
include_risk: bool,
) -> anyhow::Result<crate::terraform::plan_analyzer::PlanAnalysis> {
self.terraform_service.analyze_plan(include_risk).await
}
pub async fn review_plan(&self) -> anyhow::Result<crate::terraform::plan_review::PlanReview> {
let analysis = self.analyze_plan(true).await?;
Ok(crate::terraform::plan_review::review_plan(&analysis))
}
pub async fn summarize_plan_for_pr(
&self,
) -> anyhow::Result<crate::terraform::plan_review::PlanPrSummary> {
let analysis = self.analyze_plan(true).await?;
Ok(crate::terraform::plan_review::summarize_plan_for_pr(
&analysis,
))
}
pub async fn analyze_state(
&self,
resource_type: Option<&str>,
detect_drift: bool,
) -> anyhow::Result<crate::terraform::state_analyzer::StateAnalysis> {
self.terraform_service
.analyze_state(resource_type, detect_drift)
.await
}
pub async fn workspace(
&self,
action: &str,
name: Option<&str>,
) -> anyhow::Result<crate::terraform::workspace::WorkspaceResult> {
self.terraform_service.workspace(action, name).await
}
pub async fn import_resource(
&self,
resource_type: &str,
resource_id: &str,
name: &str,
execute: bool,
) -> anyhow::Result<serde_json::Value> {
self.terraform_service
.import_resource(resource_type, resource_id, name, execute)
.await
}
pub async fn fmt(
&self,
check: bool,
diff: bool,
file: Option<&str>,
) -> anyhow::Result<crate::terraform::fmt::FormatResult> {
self.terraform_service.fmt(check, diff, file).await
}
pub async fn graph(
&self,
graph_type: Option<&str>,
) -> anyhow::Result<crate::terraform::graph::TerraformGraph> {
self.terraform_service.graph(graph_type).await
}
pub async fn output(
&self,
name: Option<&str>,
) -> anyhow::Result<crate::terraform::output::OutputResult> {
self.terraform_service.output(name).await
}
pub async fn taint(
&self,
action: &str,
address: &str,
) -> anyhow::Result<crate::terraform::taint::TaintResult> {
self.terraform_service.taint(action, address).await
}
pub async fn refresh_state(
&self,
target: Option<&str>,
) -> anyhow::Result<crate::terraform::refresh::RefreshResult> {
self.terraform_service.refresh_state(target).await
}
pub async fn get_providers(
&self,
include_lock: bool,
) -> anyhow::Result<crate::terraform::providers::ProvidersResult> {
self.terraform_service.get_providers(include_lock).await
}
pub async fn check_provider_lockfile(
&self,
) -> anyhow::Result<crate::terraform::providers::ProviderLockfileCheck> {
crate::terraform::providers::check_provider_lockfile(&self.get_project_directory())
}
pub async fn run_quality_checks(&self) -> anyhow::Result<TerraformQualityReport> {
let project_directory = self.get_project_directory().display().to_string();
let mut checks = Vec::new();
checks.push(match self.inspect_project().await {
Ok(inspection) if inspection.total_tf_files > 0 && inspection.warnings.is_empty() => {
TerraformQualityCheck::passed(
"project_inspection",
format!(
"{} Terraform files across {} directories",
inspection.total_tf_files, inspection.total_directories
),
)
}
Ok(inspection) if inspection.total_tf_files > 0 => TerraformQualityCheck::new(
"project_inspection",
true,
QualitySeverity::Warning,
format!(
"{} Terraform files with {} warning(s)",
inspection.total_tf_files,
inspection.warnings.len()
),
inspection.warnings,
Vec::new(),
),
Ok(_) => TerraformQualityCheck::new(
"project_inspection",
false,
QualitySeverity::Error,
"No Terraform files found",
Vec::new(),
vec!["Point TERRAFORM_DIR at a Terraform root module".to_string()],
),
Err(error) => TerraformQualityCheck::new(
"project_inspection",
false,
QualitySeverity::Error,
"Project inspection failed",
vec![error.to_string()],
Vec::new(),
),
});
match self.validate_configuration_detailed().await {
Ok(validation) => {
checks.push(validation_quality_check(&validation));
if let Some(guidelines) = &validation.guideline_checks {
checks.push(guideline_quality_check(guidelines));
}
}
Err(error) => checks.push(TerraformQualityCheck::new(
"terraform_validate",
false,
QualitySeverity::Error,
"Terraform validation failed",
vec![error.to_string()],
vec!["Run terraform validate locally and fix reported diagnostics".to_string()],
)),
}
checks.push(match self.analyze_module_health().await {
Ok(health) => module_health_quality_check(&health),
Err(error) => TerraformQualityCheck::new(
"module_health",
false,
QualitySeverity::Error,
"Module health analysis failed",
vec![error.to_string()],
Vec::new(),
),
});
checks.push(match self.check_provider_lockfile().await {
Ok(lockfile) if lockfile.lockfile_exists && lockfile.warnings.is_empty() => {
TerraformQualityCheck::passed(
"provider_lockfile",
format!("{} providers are locked", lockfile.provider_count),
)
}
Ok(lockfile) if lockfile.lockfile_exists => TerraformQualityCheck::new(
"provider_lockfile",
true,
QualitySeverity::Warning,
format!(
"{} providers are locked with {} warning(s)",
lockfile.provider_count,
lockfile.warnings.len()
),
lockfile.warnings,
lockfile.recommendations,
),
Ok(lockfile) => TerraformQualityCheck::new(
"provider_lockfile",
false,
QualitySeverity::Error,
"Provider lockfile is missing",
lockfile.warnings,
lockfile.recommendations,
),
Err(error) => TerraformQualityCheck::new(
"provider_lockfile",
false,
QualitySeverity::Error,
"Provider lockfile check failed",
vec![error.to_string()],
Vec::new(),
),
});
Ok(TerraformQualityReport::new(project_directory, checks))
}
pub async fn inspect_state_safety(&self) -> anyhow::Result<StateSafetyInspection> {
let lockfile = self.check_provider_lockfile().await?;
let state = self
.analyze_state(None, true)
.await
.map_err(|error| error.to_string());
Ok(crate::terraform::state_safety::inspect_state_safety(
&self.get_project_directory(),
state,
lockfile,
))
}
pub async fn detect_drift_candidates(&self) -> anyhow::Result<DriftCandidates> {
let state = self
.analyze_state(None, true)
.await
.map_err(|error| error.to_string());
Ok(crate::terraform::state_safety::drift_candidates(
&self.get_project_directory(),
state,
))
}
pub async fn prepare_terraform_change(&self) -> anyhow::Result<TerraformChangePreparation> {
let inspection = self.inspect_state_safety().await?;
Ok(crate::terraform::state_safety::prepare_change(inspection))
}
}
fn validation_quality_check(validation: &DetailedValidationResult) -> TerraformQualityCheck {
let summary = format!(
"{} error(s), {} warning(s), {} checked file(s)",
validation.error_count, validation.warning_count, validation.checked_files
);
if validation.valid && validation.error_count == 0 {
let details = validation.additional_warnings.clone();
if details.is_empty() && validation.warning_count == 0 {
TerraformQualityCheck::passed("terraform_validate", summary)
} else {
TerraformQualityCheck::new(
"terraform_validate",
true,
QualitySeverity::Warning,
summary,
details,
validation.suggestions.clone(),
)
}
} else {
TerraformQualityCheck::new(
"terraform_validate",
false,
QualitySeverity::Error,
summary,
validation
.diagnostics
.iter()
.map(|diagnostic| diagnostic.summary.clone())
.collect(),
validation.suggestions.clone(),
)
}
}
fn guideline_quality_check(guidelines: &GuidelineCheckResult) -> TerraformQualityCheck {
let summary = format!("{}% guideline compliance", guidelines.compliance_score);
let mut details = Vec::new();
details.extend(
guidelines
.providers_missing_version
.iter()
.map(|provider| format!("Provider {provider} is missing a version constraint")),
);
details.extend(
guidelines
.hardcoded_secrets
.iter()
.map(|secret| format!("Potential secret in {}:{}", secret.file, secret.line)),
);
details.extend(
guidelines
.missing_lifecycle_protection
.iter()
.map(|resource| format!("{resource} is missing lifecycle.prevent_destroy")),
);
if guidelines.compliance_score >= 80 && guidelines.hardcoded_secrets.is_empty() {
if details.is_empty() {
TerraformQualityCheck::passed("terraform_guidelines", summary)
} else {
TerraformQualityCheck::new(
"terraform_guidelines",
true,
QualitySeverity::Warning,
summary,
details,
Vec::new(),
)
}
} else {
TerraformQualityCheck::new(
"terraform_guidelines",
false,
QualitySeverity::Error,
summary,
details,
vec!["Resolve guideline warnings before merging infrastructure changes".to_string()],
)
}
}
fn module_health_quality_check(health: &ModuleHealthAnalysis) -> TerraformQualityCheck {
let critical_issues = health
.issues
.iter()
.filter(|issue| issue.severity == crate::terraform::model::health::IssueSeverity::Critical)
.count();
let summary = format!(
"health score {}, {} issue(s)",
health.health_score,
health.issues.len()
);
let details = health
.issues
.iter()
.map(|issue| issue.message.clone())
.collect::<Vec<_>>();
if health.health_score >= 70 && critical_issues == 0 {
if health.issues.is_empty() {
TerraformQualityCheck::passed("module_health", summary)
} else {
TerraformQualityCheck::new(
"module_health",
true,
QualitySeverity::Warning,
summary,
details,
health.recommendations.clone(),
)
}
} else {
TerraformQualityCheck::new(
"module_health",
false,
QualitySeverity::Error,
summary,
details,
health.recommendations.clone(),
)
}
}