1use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17use std::process::Stdio;
18use tokio::process::Command;
19
20pub async fn check_terraform_installed() -> Option<String> {
22 let output = Command::new("terraform")
23 .arg("--version")
24 .stdout(Stdio::piped())
25 .stderr(Stdio::piped())
26 .output()
27 .await
28 .ok()?;
29
30 if output.status.success() {
31 let version = String::from_utf8_lossy(&output.stdout);
32 version.lines().next().map(|s| s.to_string())
34 } else {
35 None
36 }
37}
38
39pub fn get_installation_instructions() -> (&'static str, &'static str, Vec<&'static str>) {
41 #[cfg(target_os = "macos")]
42 {
43 (
44 "macOS",
45 "Install Terraform using Homebrew",
46 vec!["brew tap hashicorp/tap", "brew install hashicorp/tap/terraform"],
47 )
48 }
49
50 #[cfg(target_os = "linux")]
51 {
52 if std::path::Path::new("/etc/debian_version").exists() {
54 (
55 "Linux (Debian/Ubuntu)",
56 "Install Terraform using apt",
57 vec![
58 "sudo apt-get update && sudo apt-get install -y gnupg software-properties-common",
59 "wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null",
60 "echo \"deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/hashicorp.list",
61 "sudo apt update && sudo apt-get install terraform",
62 ],
63 )
64 } else if std::path::Path::new("/etc/redhat-release").exists() {
65 (
66 "Linux (RHEL/CentOS/Fedora)",
67 "Install Terraform using dnf/yum",
68 vec![
69 "sudo dnf install -y dnf-plugins-core || sudo yum install -y yum-utils",
70 "sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo || sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo",
71 "sudo dnf -y install terraform || sudo yum -y install terraform",
72 ],
73 )
74 } else {
75 (
76 "Linux",
77 "Install Terraform manually",
78 vec![
79 "curl -fsSL https://releases.hashicorp.com/terraform/1.6.6/terraform_1.6.6_linux_amd64.zip -o terraform.zip",
80 "unzip terraform.zip && sudo mv terraform /usr/local/bin/",
81 "rm terraform.zip",
82 ],
83 )
84 }
85 }
86
87 #[cfg(target_os = "windows")]
88 {
89 (
90 "Windows",
91 "Install Terraform using Chocolatey or Scoop",
92 vec!["choco install terraform", "# OR: scoop install terraform"],
93 )
94 }
95
96 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
97 {
98 (
99 "Unknown OS",
100 "Download from HashiCorp",
101 vec!["Visit https://developer.hashicorp.com/terraform/downloads"],
102 )
103 }
104}
105
106pub async fn install_terraform() -> Result<String, String> {
108 let (os, _desc, commands) = get_installation_instructions();
109
110 let mut results = Vec::new();
111
112 for cmd in commands {
113 if cmd.starts_with('#') {
115 continue;
116 }
117
118 let output = Command::new("sh")
119 .arg("-c")
120 .arg(cmd)
121 .stdout(Stdio::piped())
122 .stderr(Stdio::piped())
123 .output()
124 .await
125 .map_err(|e| format!("Failed to execute command: {}", e))?;
126
127 if !output.status.success() {
128 let stderr = String::from_utf8_lossy(&output.stderr);
129 return Err(format!(
130 "Installation failed at command '{}': {}",
131 cmd, stderr
132 ));
133 }
134
135 results.push(format!("Executed: {}", cmd));
136 }
137
138 if let Some(version) = check_terraform_installed().await {
140 Ok(format!(
141 "Terraform installed successfully on {}!\n{}\n\nInstallation steps:\n{}",
142 os,
143 version,
144 results.join("\n")
145 ))
146 } else {
147 Err("Installation completed but terraform is not in PATH. You may need to restart your terminal.".to_string())
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
153#[error("Terraform error: {0}")]
154pub struct TerraformError(pub String);
155
156#[derive(Debug, Deserialize)]
162pub struct TerraformFmtArgs {
163 #[serde(default)]
165 pub path: Option<String>,
166
167 #[serde(default)]
169 pub check: bool,
170
171 #[serde(default)]
173 pub diff: bool,
174
175 #[serde(default = "default_true")]
177 pub recursive: bool,
178}
179
180fn default_true() -> bool {
181 true
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct TerraformFmtTool {
187 project_path: PathBuf,
188}
189
190impl TerraformFmtTool {
191 pub fn new(project_path: PathBuf) -> Self {
192 Self { project_path }
193 }
194
195 fn format_result(
196 &self,
197 success: bool,
198 files_changed: Vec<String>,
199 diff_output: Option<String>,
200 check_mode: bool,
201 ) -> String {
202 let decision_context = if files_changed.is_empty() {
203 "All Terraform files are properly formatted. No changes needed."
204 } else if check_mode {
205 "Formatting issues detected. Run terraform fmt to fix, or use this tool with check=false."
206 } else {
207 "Terraform files have been formatted successfully."
208 };
209
210 let output = json!({
211 "success": success,
212 "decision_context": decision_context,
213 "summary": {
214 "files_checked": if check_mode { "check mode" } else { "format mode" },
215 "files_needing_format": files_changed.len(),
216 "action_taken": if check_mode { "none (check only)" } else { "formatted" },
217 },
218 "files": files_changed,
219 "diff": diff_output,
220 "recommendations": if !files_changed.is_empty() && check_mode {
221 Some(vec![
222 "Run `terraform fmt` to automatically fix formatting",
223 "Consider adding pre-commit hooks for consistent formatting",
224 "Use `terraform fmt -recursive` for nested modules"
225 ])
226 } else {
227 None
228 }
229 });
230
231 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
232 }
233}
234
235impl Tool for TerraformFmtTool {
236 const NAME: &'static str = "terraform_fmt";
237
238 type Error = TerraformError;
239 type Args = TerraformFmtArgs;
240 type Output = String;
241
242 async fn definition(&self, _prompt: String) -> ToolDefinition {
243 ToolDefinition {
244 name: Self::NAME.to_string(),
245 description: "Format Terraform configuration files to canonical style. \
246 Returns AI-optimized JSON showing which files need formatting or were formatted. \
247 Use check=true to verify without modifying files. \
248 Use diff=true to see the exact changes. \
249 The tool automatically handles recursive formatting for modules."
250 .to_string(),
251 parameters: json!({
252 "type": "object",
253 "properties": {
254 "path": {
255 "type": "string",
256 "description": "Path to terraform files/directory relative to project root (default: project root)"
257 },
258 "check": {
259 "type": "boolean",
260 "description": "Check mode - report files needing format without modifying them (default: false)"
261 },
262 "diff": {
263 "type": "boolean",
264 "description": "Show diff of formatting changes (default: false)"
265 },
266 "recursive": {
267 "type": "boolean",
268 "description": "Process files recursively in subdirectories (default: true)"
269 }
270 }
271 }),
272 }
273 }
274
275 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
276 if check_terraform_installed().await.is_none() {
278 let (os, desc, commands) = get_installation_instructions();
279 let install_info = json!({
280 "error": "terraform_not_installed",
281 "message": "Terraform CLI is not installed or not in PATH",
282 "os_detected": os,
283 "installation": {
284 "description": desc,
285 "commands": commands
286 },
287 "action_required": "Ask user if they want to install Terraform, then use terraform_install tool"
288 });
289 return Ok(serde_json::to_string_pretty(&install_info).unwrap());
290 }
291
292 let work_dir = match &args.path {
294 Some(p) => self.project_path.join(p),
295 None => self.project_path.clone(),
296 };
297
298 if !work_dir.exists() {
299 return Err(TerraformError(format!(
300 "Path does not exist: {}",
301 work_dir.display()
302 )));
303 }
304
305 let mut cmd = Command::new("terraform");
307 cmd.arg("fmt");
308
309 if args.check {
310 cmd.arg("-check");
311 }
312 if args.diff {
313 cmd.arg("-diff");
314 }
315 if args.recursive {
316 cmd.arg("-recursive");
317 }
318
319 cmd.arg("-list=true");
321 cmd.current_dir(&work_dir);
322 cmd.stdout(Stdio::piped());
323 cmd.stderr(Stdio::piped());
324
325 let output = cmd
326 .output()
327 .await
328 .map_err(|e| TerraformError(format!("Failed to execute terraform fmt: {}", e)))?;
329
330 let stdout = String::from_utf8_lossy(&output.stdout);
331 let stderr = String::from_utf8_lossy(&output.stderr);
332
333 let files_changed: Vec<String> = stdout
335 .lines()
336 .filter(|l| !l.is_empty())
337 .map(|s| s.to_string())
338 .collect();
339
340 let diff_output = if args.diff && !stdout.is_empty() {
342 Some(stdout.to_string())
343 } else {
344 None
345 };
346
347 let success = output.status.success() || (args.check && output.status.code() == Some(3));
349
350 if !success && !stderr.is_empty() {
351 return Err(TerraformError(format!("terraform fmt failed: {}", stderr)));
352 }
353
354 Ok(self.format_result(success, files_changed, diff_output, args.check))
355 }
356}
357
358#[derive(Debug, Deserialize)]
364pub struct TerraformValidateArgs {
365 #[serde(default)]
367 pub path: Option<String>,
368
369 #[serde(default)]
371 pub auto_init: bool,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct TerraformValidateTool {
377 project_path: PathBuf,
378}
379
380impl TerraformValidateTool {
381 pub fn new(project_path: PathBuf) -> Self {
382 Self { project_path }
383 }
384
385 fn categorize_error(message: &str) -> (&'static str, &'static str) {
387 let msg_lower = message.to_lowercase();
388
389 if msg_lower.contains("syntax") || msg_lower.contains("parse") {
391 ("syntax", "critical")
392 } else if msg_lower.contains("deprecated") {
393 ("deprecation", "medium")
394 } else if msg_lower.contains("provider") {
395 ("provider", "high")
396 } else if msg_lower.contains("resource")
397 || msg_lower.contains("data source")
398 || msg_lower.contains("module")
399 {
400 ("resource", "high")
401 } else if msg_lower.contains("variable") || msg_lower.contains("output") {
402 ("configuration", "medium")
403 } else {
404 ("general", "medium")
405 }
406 }
407
408 fn get_fix_recommendation(message: &str) -> &'static str {
410 let msg_lower = message.to_lowercase();
411
412 if msg_lower.contains("provider") && msg_lower.contains("not found") {
413 "Run 'terraform init' to download required providers"
414 } else if msg_lower.contains("variable") && msg_lower.contains("not defined") {
415 "Add the missing variable to your variables.tf or provide via -var flag"
416 } else if msg_lower.contains("resource") && msg_lower.contains("not found") {
417 "Check resource type spelling and ensure provider is correctly configured"
418 } else if msg_lower.contains("syntax") {
419 "Review HCL syntax - check for missing braces, quotes, or commas"
420 } else if msg_lower.contains("deprecated") {
421 "Update to the recommended replacement as indicated in the message"
422 } else if msg_lower.contains("module") && msg_lower.contains("not found") {
423 "Run 'terraform init' to download the module or check the source path"
424 } else if msg_lower.contains("duplicate") {
425 "Remove or rename the duplicate resource/variable declaration"
426 } else {
427 "Review the error message and Terraform documentation for this resource type"
428 }
429 }
430
431 fn format_result(&self, validation_output: &str, success: bool, init_output: Option<&str>) -> String {
432 if let Ok(tf_json) = serde_json::from_str::<serde_json::Value>(validation_output) {
434 let valid = tf_json["valid"].as_bool().unwrap_or(false);
435 let error_count = tf_json["error_count"].as_u64().unwrap_or(0);
436 let warning_count = tf_json["warning_count"].as_u64().unwrap_or(0);
437
438 let diagnostics = tf_json["diagnostics"].as_array();
439
440 let mut categorized_issues: Vec<serde_json::Value> = Vec::new();
441 let mut by_category: std::collections::HashMap<&str, usize> =
442 std::collections::HashMap::new();
443 let mut by_priority: std::collections::HashMap<&str, usize> =
444 std::collections::HashMap::new();
445
446 if let Some(diags) = diagnostics {
447 for diag in diags {
448 let severity = diag["severity"].as_str().unwrap_or("error");
449 let summary = diag["summary"].as_str().unwrap_or("");
450 let detail = diag["detail"].as_str().unwrap_or("");
451 let message = format!("{}: {}", summary, detail);
452
453 let (category, priority) = Self::categorize_error(&message);
454 let fix = Self::get_fix_recommendation(&message);
455
456 *by_category.entry(category).or_insert(0) += 1;
457 *by_priority.entry(priority).or_insert(0) += 1;
458
459 let range = &diag["range"];
460 let filename = range["filename"].as_str().unwrap_or("");
461 let start_line = range["start"]["line"].as_u64().unwrap_or(0);
462
463 categorized_issues.push(json!({
464 "severity": severity,
465 "priority": priority,
466 "category": category,
467 "summary": summary,
468 "detail": detail,
469 "fix": fix,
470 "location": {
471 "file": filename,
472 "line": start_line
473 }
474 }));
475 }
476 }
477
478 let decision_context = if valid {
479 "Terraform configuration is valid. Ready for plan/apply."
480 } else if by_priority.get("critical").unwrap_or(&0) > &0 {
481 "Critical syntax errors found. Fix these before proceeding."
482 } else if error_count > 0 {
483 "Configuration errors found. Review and fix before applying."
484 } else {
485 "Warnings found. Consider addressing for best practices."
486 };
487
488 let output = json!({
489 "success": valid,
490 "decision_context": decision_context,
491 "summary": {
492 "valid": valid,
493 "errors": error_count,
494 "warnings": warning_count,
495 "by_category": by_category,
496 "by_priority": by_priority,
497 },
498 "issues": categorized_issues,
499 "init_output": init_output,
500 "quick_fixes": categorized_issues.iter()
501 .filter(|i| i["priority"] == "critical" || i["priority"] == "high")
502 .take(5)
503 .map(|i| format!("{}: {} - {}",
504 i["location"]["file"].as_str().unwrap_or(""),
505 i["summary"].as_str().unwrap_or(""),
506 i["fix"].as_str().unwrap_or("")
507 ))
508 .collect::<Vec<_>>()
509 });
510
511 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
512 } else {
513 let output = json!({
515 "success": success,
516 "decision_context": if success {
517 "Terraform configuration is valid."
518 } else {
519 "Validation failed. Review errors below."
520 },
521 "raw_output": validation_output,
522 "init_output": init_output
523 });
524 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
525 }
526 }
527}
528
529impl Tool for TerraformValidateTool {
530 const NAME: &'static str = "terraform_validate";
531
532 type Error = TerraformError;
533 type Args = TerraformValidateArgs;
534 type Output = String;
535
536 async fn definition(&self, _prompt: String) -> ToolDefinition {
537 ToolDefinition {
538 name: Self::NAME.to_string(),
539 description: "Validate Terraform configuration for syntax and internal consistency. \
540 Returns AI-optimized JSON with categorized issues (syntax/provider/resource/configuration), \
541 priority rankings (critical/high/medium), and actionable fix recommendations. \
542 Use auto_init=true to automatically run 'terraform init' if providers aren't downloaded. \
543 The 'decision_context' field provides a summary for quick assessment."
544 .to_string(),
545 parameters: json!({
546 "type": "object",
547 "properties": {
548 "path": {
549 "type": "string",
550 "description": "Path to terraform directory relative to project root (default: project root)"
551 },
552 "auto_init": {
553 "type": "boolean",
554 "description": "Automatically run 'terraform init' if needed (default: false)"
555 }
556 }
557 }),
558 }
559 }
560
561 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
562 if check_terraform_installed().await.is_none() {
564 let (os, desc, commands) = get_installation_instructions();
565 let install_info = json!({
566 "error": "terraform_not_installed",
567 "message": "Terraform CLI is not installed or not in PATH",
568 "os_detected": os,
569 "installation": {
570 "description": desc,
571 "commands": commands
572 },
573 "action_required": "Ask user if they want to install Terraform, then use terraform_install tool"
574 });
575 return Ok(serde_json::to_string_pretty(&install_info).unwrap());
576 }
577
578 let work_dir = match &args.path {
580 Some(p) => self.project_path.join(p),
581 None => self.project_path.clone(),
582 };
583
584 if !work_dir.exists() {
585 return Err(TerraformError(format!(
586 "Path does not exist: {}",
587 work_dir.display()
588 )));
589 }
590
591 let mut init_output = None;
592
593 if args.auto_init {
595 let init_result = Command::new("terraform")
596 .args(["init", "-backend=false", "-input=false"])
597 .current_dir(&work_dir)
598 .stdout(Stdio::piped())
599 .stderr(Stdio::piped())
600 .output()
601 .await;
602
603 if let Ok(output) = init_result {
604 let stdout = String::from_utf8_lossy(&output.stdout);
605 let stderr = String::from_utf8_lossy(&output.stderr);
606 init_output = Some(format!("{}{}", stdout, stderr));
607 }
608 }
609
610 let output = Command::new("terraform")
612 .args(["validate", "-json"])
613 .current_dir(&work_dir)
614 .stdout(Stdio::piped())
615 .stderr(Stdio::piped())
616 .output()
617 .await
618 .map_err(|e| TerraformError(format!("Failed to execute terraform validate: {}", e)))?;
619
620 let stdout = String::from_utf8_lossy(&output.stdout);
621 let stderr = String::from_utf8_lossy(&output.stderr);
622
623 let validation_output = if !stdout.is_empty() {
625 stdout.to_string()
626 } else {
627 stderr.to_string()
628 };
629
630 Ok(self.format_result(
631 &validation_output,
632 output.status.success(),
633 init_output.as_deref(),
634 ))
635 }
636}
637
638#[derive(Debug, Deserialize)]
644pub struct TerraformInstallArgs {
645 #[serde(default)]
647 pub confirm: bool,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct TerraformInstallTool;
653
654impl TerraformInstallTool {
655 pub fn new() -> Self {
656 Self
657 }
658}
659
660impl Default for TerraformInstallTool {
661 fn default() -> Self {
662 Self::new()
663 }
664}
665
666impl Tool for TerraformInstallTool {
667 const NAME: &'static str = "terraform_install";
668
669 type Error = TerraformError;
670 type Args = TerraformInstallArgs;
671 type Output = String;
672
673 async fn definition(&self, _prompt: String) -> ToolDefinition {
674 ToolDefinition {
675 name: Self::NAME.to_string(),
676 description: "Install Terraform CLI on the current system. \
677 Automatically detects the operating system and uses the appropriate package manager \
678 (Homebrew on macOS, apt on Debian/Ubuntu, dnf/yum on RHEL/Fedora). \
679 Requires confirm=true to proceed with installation."
680 .to_string(),
681 parameters: json!({
682 "type": "object",
683 "properties": {
684 "confirm": {
685 "type": "boolean",
686 "description": "Set to true to confirm and proceed with installation"
687 }
688 },
689 "required": ["confirm"]
690 }),
691 }
692 }
693
694 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
695 if let Some(version) = check_terraform_installed().await {
697 let result = json!({
698 "already_installed": true,
699 "version": version,
700 "message": "Terraform is already installed on this system"
701 });
702 return Ok(serde_json::to_string_pretty(&result).unwrap());
703 }
704
705 if !args.confirm {
707 let (os, desc, commands) = get_installation_instructions();
708 let info = json!({
709 "os_detected": os,
710 "installation_method": desc,
711 "commands_to_run": commands,
712 "action_required": "Set confirm=true to proceed with installation",
713 "warning": "This will install software on your system using elevated privileges"
714 });
715 return Ok(serde_json::to_string_pretty(&info).unwrap());
716 }
717
718 match install_terraform().await {
720 Ok(message) => {
721 let result = json!({
722 "success": true,
723 "message": message
724 });
725 Ok(serde_json::to_string_pretty(&result).unwrap())
726 }
727 Err(error) => {
728 let result = json!({
729 "success": false,
730 "error": error,
731 "suggestion": "Try installing manually or check system permissions"
732 });
733 Ok(serde_json::to_string_pretty(&result).unwrap())
734 }
735 }
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use std::env::temp_dir;
743 use std::fs;
744
745 #[tokio::test]
746 async fn test_terraform_fmt_check_mode() {
747 if check_terraform_installed().await.is_none() {
749 eprintln!("Skipping test: terraform not installed");
750 return;
751 }
752
753 let temp = temp_dir().join("tf_fmt_test");
754 fs::create_dir_all(&temp).unwrap();
755
756 let tf_content = r#"
758resource "aws_instance" "example" {
759ami = "ami-12345"
760instance_type = "t2.micro"
761}
762"#;
763 fs::write(temp.join("main.tf"), tf_content).unwrap();
764
765 let tool = TerraformFmtTool::new(temp.clone());
766 let args = TerraformFmtArgs {
767 path: None,
768 check: true,
769 diff: false,
770 recursive: false,
771 };
772
773 let result = tool.call(args).await.unwrap();
774 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
775
776 assert!(parsed["decision_context"].is_string());
778 assert!(parsed["summary"].is_object());
779
780 fs::remove_dir_all(&temp).ok();
782 }
783
784 #[tokio::test]
785 async fn test_terraform_validate_valid_config() {
786 if check_terraform_installed().await.is_none() {
788 eprintln!("Skipping test: terraform not installed");
789 return;
790 }
791
792 let temp = temp_dir().join("tf_validate_test");
793 fs::create_dir_all(&temp).unwrap();
794
795 let tf_content = r#"
797terraform {
798 required_version = ">= 1.0"
799}
800
801variable "name" {
802 type = string
803 default = "test"
804}
805
806output "result" {
807 value = var.name
808}
809"#;
810 fs::write(temp.join("main.tf"), tf_content).unwrap();
811
812 let tool = TerraformValidateTool::new(temp.clone());
813 let args = TerraformValidateArgs {
814 path: None,
815 auto_init: false,
816 };
817
818 let result = tool.call(args).await.unwrap();
819 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
820
821 assert!(parsed["success"].as_bool().unwrap_or(false));
823 assert!(parsed["decision_context"].is_string());
824
825 fs::remove_dir_all(&temp).ok();
827 }
828
829 #[tokio::test]
830 async fn test_terraform_not_installed_response() {
831 let (os, desc, commands) = get_installation_instructions();
834
835 assert!(!os.is_empty());
836 assert!(!desc.is_empty());
837 assert!(!commands.is_empty());
838 }
839
840 #[test]
841 fn test_error_categorization() {
842 let (cat, pri) = TerraformValidateTool::categorize_error("Provider aws not found");
843 assert_eq!(cat, "provider");
844 assert_eq!(pri, "high");
845
846 let (cat, pri) = TerraformValidateTool::categorize_error("Syntax error in HCL");
847 assert_eq!(cat, "syntax");
848 assert_eq!(pri, "critical");
849
850 let (cat, pri) = TerraformValidateTool::categorize_error("Variable 'foo' is deprecated");
851 assert_eq!(cat, "deprecation");
852 assert_eq!(pri, "medium");
853 }
854}