syncable_cli/agent/tools/
terraform.rs

1//! Terraform tools - Format and validate Terraform configurations
2//!
3//! Provides Terraform fmt and validate capabilities with AI-optimized output.
4//! Wraps the terraform CLI binary with structured output for agent decision-making.
5//!
6//! Features:
7//! - Auto-detection of terraform binary
8//! - OS-aware installation prompts
9//! - Categorized issues (syntax, configuration, provider, resource)
10//! - Priority rankings and actionable fix recommendations
11
12use 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
20/// Check if terraform is installed and return version info
21pub 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        // Extract first line (version info)
33        version.lines().next().map(|s| s.to_string())
34    } else {
35        None
36    }
37}
38
39/// Detect the current OS and return installation instructions
40pub 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        // Check for common package managers
53        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
106/// Install terraform for the current OS
107pub 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        // Skip comment lines
114        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    // Verify installation
139    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/// Error type for terraform tools
152#[derive(Debug, thiserror::Error)]
153#[error("Terraform error: {0}")]
154pub struct TerraformError(pub String);
155
156// ============================================================================
157// TerraformFmtTool
158// ============================================================================
159
160/// Arguments for terraform fmt
161#[derive(Debug, Deserialize)]
162pub struct TerraformFmtArgs {
163    /// Path to terraform files/directory (relative to project root)
164    #[serde(default)]
165    pub path: Option<String>,
166
167    /// Check mode - don't modify files, just report if formatting is needed
168    #[serde(default)]
169    pub check: bool,
170
171    /// Show diff of formatting changes
172    #[serde(default)]
173    pub diff: bool,
174
175    /// Process files recursively
176    #[serde(default = "default_true")]
177    pub recursive: bool,
178}
179
180fn default_true() -> bool {
181    true
182}
183
184/// Tool to format Terraform configurations
185#[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        // Check if terraform is installed
277        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        // Determine working directory
293        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        // Build command
306        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        // List files that would be/were changed
320        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        // Parse files that need formatting (one per line)
334        let files_changed: Vec<String> = stdout
335            .lines()
336            .filter(|l| !l.is_empty())
337            .map(|s| s.to_string())
338            .collect();
339
340        // Get diff if requested
341        let diff_output = if args.diff && !stdout.is_empty() {
342            Some(stdout.to_string())
343        } else {
344            None
345        };
346
347        // In check mode, exit code 3 means files need formatting (not an error)
348        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// ============================================================================
359// TerraformValidateTool
360// ============================================================================
361
362/// Arguments for terraform validate
363#[derive(Debug, Deserialize)]
364pub struct TerraformValidateArgs {
365    /// Path to terraform configuration directory (relative to project root)
366    #[serde(default)]
367    pub path: Option<String>,
368
369    /// Run terraform init first if needed
370    #[serde(default)]
371    pub auto_init: bool,
372}
373
374/// Tool to validate Terraform configurations
375#[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    /// Categorize validation errors
386    fn categorize_error(message: &str) -> (&'static str, &'static str) {
387        let msg_lower = message.to_lowercase();
388
389        // Check more specific patterns first
390        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    /// Get fix recommendation based on error
409    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        // Try to parse JSON output from terraform validate -json
433        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            // Fallback for non-JSON output
514            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        // Check if terraform is installed
563        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        // Determine working directory
579        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        // Auto-init if requested
594        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        // Run terraform validate with JSON output
611        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        // Combine output
624        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// ============================================================================
639// TerraformInstallTool
640// ============================================================================
641
642/// Arguments for terraform install
643#[derive(Debug, Deserialize)]
644pub struct TerraformInstallArgs {
645    /// Confirm installation (safety check)
646    #[serde(default)]
647    pub confirm: bool,
648}
649
650/// Tool to install Terraform CLI
651#[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        // Check if already installed
696        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        // Show installation info if not confirmed
706        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        // Proceed with installation
719        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        // Skip if terraform not installed
748        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        // Write poorly formatted terraform
757        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        // Should detect formatting needed
777        assert!(parsed["decision_context"].is_string());
778        assert!(parsed["summary"].is_object());
779
780        // Cleanup
781        fs::remove_dir_all(&temp).ok();
782    }
783
784    #[tokio::test]
785    async fn test_terraform_validate_valid_config() {
786        // Skip if terraform not installed
787        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        // Write valid terraform (minimal)
796        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        // Should be valid
822        assert!(parsed["success"].as_bool().unwrap_or(false));
823        assert!(parsed["decision_context"].is_string());
824
825        // Cleanup
826        fs::remove_dir_all(&temp).ok();
827    }
828
829    #[tokio::test]
830    async fn test_terraform_not_installed_response() {
831        // This test verifies the response format when terraform is not installed
832        // by checking the structure of the installation info
833        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}