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![
47                "brew tap hashicorp/tap",
48                "brew install hashicorp/tap/terraform",
49            ],
50        )
51    }
52
53    #[cfg(target_os = "linux")]
54    {
55        // Check for common package managers
56        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
109/// Install terraform for the current OS
110pub 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        // Skip comment lines
117        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    // Verify installation
142    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/// Error type for terraform tools
155#[derive(Debug, thiserror::Error)]
156#[error("Terraform error: {0}")]
157pub struct TerraformError(pub String);
158
159// ============================================================================
160// TerraformFmtTool
161// ============================================================================
162
163/// Arguments for terraform fmt
164#[derive(Debug, Deserialize)]
165pub struct TerraformFmtArgs {
166    /// Path to terraform files/directory (relative to project root)
167    #[serde(default)]
168    pub path: Option<String>,
169
170    /// Check mode - don't modify files, just report if formatting is needed
171    #[serde(default)]
172    pub check: bool,
173
174    /// Show diff of formatting changes
175    #[serde(default)]
176    pub diff: bool,
177
178    /// Process files recursively
179    #[serde(default = "default_true")]
180    pub recursive: bool,
181}
182
183fn default_true() -> bool {
184    true
185}
186
187/// Tool to format Terraform configurations
188#[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        // Check if terraform is installed
280        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        // Determine working directory
296        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        // Build command
309        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        // List files that would be/were changed
323        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        // Parse files that need formatting (one per line)
337        let files_changed: Vec<String> = stdout
338            .lines()
339            .filter(|l| !l.is_empty())
340            .map(|s| s.to_string())
341            .collect();
342
343        // Get diff if requested
344        let diff_output = if args.diff && !stdout.is_empty() {
345            Some(stdout.to_string())
346        } else {
347            None
348        };
349
350        // In check mode, exit code 3 means files need formatting (not an error)
351        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// ============================================================================
362// TerraformValidateTool
363// ============================================================================
364
365/// Arguments for terraform validate
366#[derive(Debug, Deserialize)]
367pub struct TerraformValidateArgs {
368    /// Path to terraform configuration directory (relative to project root)
369    #[serde(default)]
370    pub path: Option<String>,
371
372    /// Run terraform init first if needed
373    #[serde(default)]
374    pub auto_init: bool,
375}
376
377/// Tool to validate Terraform configurations
378#[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    /// Categorize validation errors
389    fn categorize_error(message: &str) -> (&'static str, &'static str) {
390        let msg_lower = message.to_lowercase();
391
392        // Check more specific patterns first
393        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    /// Get fix recommendation based on error
412    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        // Try to parse JSON output from terraform validate -json
441        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            // Fallback for non-JSON output
522            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        // Check if terraform is installed
571        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        // Determine working directory
587        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        // Auto-init if requested
602        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        // Run terraform validate with JSON output
619        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        // Combine output
632        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// ============================================================================
647// TerraformInstallTool
648// ============================================================================
649
650/// Arguments for terraform install
651#[derive(Debug, Deserialize)]
652pub struct TerraformInstallArgs {
653    /// Confirm installation (safety check)
654    #[serde(default)]
655    pub confirm: bool,
656}
657
658/// Tool to install Terraform CLI
659#[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        // Check if already installed
704        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        // Show installation info if not confirmed
714        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        // Proceed with installation
727        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        // Skip if terraform not installed
756        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        // Write poorly formatted terraform
765        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        // Should detect formatting needed
785        assert!(parsed["decision_context"].is_string());
786        assert!(parsed["summary"].is_object());
787
788        // Cleanup
789        fs::remove_dir_all(&temp).ok();
790    }
791
792    #[tokio::test]
793    async fn test_terraform_validate_valid_config() {
794        // Skip if terraform not installed
795        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        // Write valid terraform (minimal)
804        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        // Should be valid
830        assert!(parsed["success"].as_bool().unwrap_or(false));
831        assert!(parsed["decision_context"].is_string());
832
833        // Cleanup
834        fs::remove_dir_all(&temp).ok();
835    }
836
837    #[tokio::test]
838    async fn test_terraform_not_installed_response() {
839        // This test verifies the response format when terraform is not installed
840        // by checking the structure of the installation info
841        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}