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