tfmcp
by nwiizo
- src
- terraform
use crate::terraform::model::{TerraformAnalysis, TerraformResource};
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TerraformError {
#[error("Terraform command failed: {0}")]
CommandFailed(String),
#[error("Terraform executable not found at: {0}")]
ExecutableNotFound(String),
#[error("Invalid Terraform project directory: {0}")]
InvalidProjectDirectory(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse Terraform output: {0}")]
#[allow(dead_code)]
ParseError(String),
}
pub struct TerraformService {
terraform_path: PathBuf,
project_directory: PathBuf,
}
impl TerraformService {
pub fn new(
terraform_path: PathBuf,
project_directory: PathBuf,
) -> Result<Self, TerraformError> {
// Validate terraform path
if !terraform_path.exists() {
return Err(TerraformError::ExecutableNotFound(
terraform_path.to_string_lossy().to_string(),
));
}
// Validate project directory
if !project_directory.exists() || !project_directory.is_dir() {
return Err(TerraformError::InvalidProjectDirectory(
project_directory.to_string_lossy().to_string(),
));
}
// Check if the directory contains terraform files
let has_tf_files = std::fs::read_dir(&project_directory)?
.filter_map(Result::ok)
.any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf"));
if !has_tf_files {
return Err(TerraformError::InvalidProjectDirectory(format!(
"No Terraform (.tf) files found in {}",
project_directory.display()
)));
}
Ok(Self {
terraform_path,
project_directory,
})
}
pub fn change_project_directory(
&mut self,
new_directory: PathBuf,
) -> Result<(), TerraformError> {
// Validate new project directory
if !new_directory.exists() || !new_directory.is_dir() {
return Err(TerraformError::InvalidProjectDirectory(
new_directory.to_string_lossy().to_string(),
));
}
// Check if the directory contains terraform files
let has_tf_files = std::fs::read_dir(&new_directory)?
.filter_map(Result::ok)
.any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf"));
if !has_tf_files {
return Err(TerraformError::InvalidProjectDirectory(format!(
"No Terraform (.tf) files found in {}",
new_directory.display()
)));
}
// ę°ćććć£ć¬ćÆććŖć«å¤ę“
self.project_directory = new_directory;
Ok(())
}
pub fn get_project_directory(&self) -> &PathBuf {
&self.project_directory
}
#[allow(dead_code)]
pub async fn get_version(&self) -> anyhow::Result<String> {
let output = Command::new(&self.terraform_path)
.arg("version")
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn init(&self) -> anyhow::Result<String> {
let output = Command::new(&self.terraform_path)
.args(["init", "-no-color"])
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn get_plan(&self) -> anyhow::Result<String> {
// Run terraform plan and capture output
let output = Command::new(&self.terraform_path)
.args(["plan", "-no-color"])
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn apply(&self, auto_approve: bool) -> anyhow::Result<String> {
let mut args = vec!["apply", "-no-color"];
if auto_approve {
args.push("-auto-approve");
}
let output = Command::new(&self.terraform_path)
.args(&args)
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn get_state(&self) -> anyhow::Result<String> {
let output = Command::new(&self.terraform_path)
.args(["show", "-no-color"])
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn list_resources(&self) -> anyhow::Result<Vec<String>> {
let output = Command::new(&self.terraform_path)
.args(["state", "list"])
.current_dir(&self.project_directory)
.output()?;
if !output.status.success() {
return Err(TerraformError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
)
.into());
}
let resources = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.collect();
Ok(resources)
}
pub async fn validate(&self) -> anyhow::Result<String> {
let output = Command::new(&self.terraform_path)
.arg("validate")
.arg("-json")
.current_dir(&self.project_directory)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(anyhow::anyhow!(
"Terraform validate failed: {}",
String::from_utf8_lossy(&output.stderr)
))
}
}
pub async fn destroy(&self, auto_approve: bool) -> anyhow::Result<String> {
let mut cmd = Command::new(&self.terraform_path);
cmd.arg("destroy");
if auto_approve {
cmd.arg("-auto-approve");
}
let output = cmd.current_dir(&self.project_directory).output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(anyhow::anyhow!(
"Terraform destroy failed: {}",
String::from_utf8_lossy(&output.stderr)
))
}
}
pub async fn analyze_configurations(&self) -> anyhow::Result<TerraformAnalysis> {
eprintln!(
"[DEBUG] Analyzing Terraform configurations in {}",
self.project_directory.display()
);
// Check if the directory exists
if !self.project_directory.exists() {
return Err(anyhow::anyhow!(
"Project directory does not exist: {}",
self.project_directory.display()
));
}
// Find all .tf files in the project directory
let entries = std::fs::read_dir(&self.project_directory)?;
let mut tf_files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "tf") {
eprintln!("[DEBUG] Found Terraform file: {}", path.display());
tf_files.push(path);
}
}
if tf_files.is_empty() {
eprintln!(
"[WARN] No Terraform (.tf) files found in {}",
self.project_directory.display()
);
return Err(anyhow::anyhow!(
"No Terraform (.tf) files found in {}",
self.project_directory.display()
));
}
let mut analysis = TerraformAnalysis {
project_directory: self.project_directory.to_string_lossy().to_string(),
file_count: tf_files.len(),
resources: Vec::new(),
variables: Vec::new(),
outputs: Vec::new(),
providers: Vec::new(),
};
// Parse each file to identify resources, variables, outputs
for file_path in tf_files {
eprintln!("[DEBUG] Analyzing file: {}", file_path.display());
match self.analyze_file(&file_path, &mut analysis) {
Ok(_) => eprintln!("[DEBUG] Successfully analyzed {}", file_path.display()),
Err(e) => eprintln!("[ERROR] Failed to analyze {}: {}", file_path.display(), e),
}
}
eprintln!("[INFO] Terraform analysis complete: found {} resources, {} variables, {} outputs, {} providers",
analysis.resources.len(), analysis.variables.len(), analysis.outputs.len(), analysis.providers.len());
Ok(analysis)
}
fn analyze_file(
&self,
file_path: &Path,
analysis: &mut TerraformAnalysis,
) -> anyhow::Result<()> {
eprintln!("[DEBUG] Reading file: {}", file_path.display());
let content = match std::fs::read_to_string(file_path) {
Ok(content) => content,
Err(e) => {
eprintln!("[ERROR] Failed to read file {}: {}", file_path.display(), e);
return Err(anyhow::anyhow!("Failed to read file: {}", e));
}
};
let file_name = file_path.file_name().unwrap_or_default().to_string_lossy();
// Very basic parsing for demonstration purposes
// In a real implementation, you would want to use a proper HCL parser
eprintln!("[DEBUG] Parsing resources in {}", file_path.display());
// Find resources
let resource_regex = regex::Regex::new(r#"resource\s+"([^"]+)"\s+"([^"]+)"#).unwrap();
for captures in resource_regex.captures_iter(&content) {
if captures.len() >= 3 {
let resource_type = captures[1].to_string();
let resource_name = captures[2].to_string();
eprintln!(
"[DEBUG] Found resource: {} ({})",
resource_name, resource_type
);
analysis.resources.push(TerraformResource {
resource_type,
name: resource_name,
file: file_name.to_string(),
});
}
}
eprintln!("[DEBUG] Parsing variables in {}", file_path.display());
// Find variables
let variable_regex = regex::Regex::new(r#"variable\s+"([^"]+)"#).unwrap();
for captures in variable_regex.captures_iter(&content) {
if captures.len() >= 2 {
let variable_name = captures[1].to_string();
eprintln!("[DEBUG] Found variable: {}", variable_name);
analysis.variables.push(variable_name);
}
}
eprintln!("[DEBUG] Parsing outputs in {}", file_path.display());
// Find outputs
let output_regex = regex::Regex::new(r#"output\s+"([^"]+)"#).unwrap();
for captures in output_regex.captures_iter(&content) {
if captures.len() >= 2 {
let output_name = captures[1].to_string();
eprintln!("[DEBUG] Found output: {}", output_name);
analysis.outputs.push(output_name);
}
}
eprintln!("[DEBUG] Parsing providers in {}", file_path.display());
// Find providers
let provider_regex = regex::Regex::new(r#"provider\s+"([^"]+)"#).unwrap();
for captures in provider_regex.captures_iter(&content) {
if captures.len() >= 2 {
let provider_name = captures[1].to_string();
if !analysis.providers.contains(&provider_name) {
eprintln!("[DEBUG] Found provider: {}", provider_name);
analysis.providers.push(provider_name);
}
}
}
eprintln!("[DEBUG] Completed analysis of {}", file_path.display());
Ok(())
}
}