tfmcp/terraform/
fmt.rs

1//! Terraform fmt operations for code formatting.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use std::process::Command;
6
7/// Format check result for a single file
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FileFormatResult {
10    pub file: String,
11    pub formatted: bool,
12    pub diff: Option<String>,
13}
14
15/// Overall format result
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct FormatResult {
18    pub success: bool,
19    pub files_checked: i32,
20    pub files_formatted: i32,
21    pub files_unchanged: i32,
22    pub file_results: Vec<FileFormatResult>,
23    pub message: String,
24}
25
26/// Check formatting without making changes
27pub fn check_format(
28    terraform_path: &Path,
29    project_dir: &Path,
30    file: Option<&str>,
31) -> anyhow::Result<FormatResult> {
32    format_internal(terraform_path, project_dir, file, true, false)
33}
34
35/// Format files and show diff
36pub fn format_with_diff(
37    terraform_path: &Path,
38    project_dir: &Path,
39    file: Option<&str>,
40) -> anyhow::Result<FormatResult> {
41    format_internal(terraform_path, project_dir, file, false, true)
42}
43
44/// Format files in place
45pub fn format_files(
46    terraform_path: &Path,
47    project_dir: &Path,
48    file: Option<&str>,
49) -> anyhow::Result<FormatResult> {
50    format_internal(terraform_path, project_dir, file, false, false)
51}
52
53/// Internal format implementation
54fn format_internal(
55    terraform_path: &Path,
56    project_dir: &Path,
57    file: Option<&str>,
58    check_only: bool,
59    show_diff: bool,
60) -> anyhow::Result<FormatResult> {
61    let mut cmd = Command::new(terraform_path);
62    cmd.arg("fmt");
63
64    if check_only {
65        cmd.arg("-check");
66    }
67
68    if show_diff {
69        cmd.arg("-diff");
70    }
71
72    // List files that would be formatted
73    cmd.arg("-list=true");
74
75    // Recursive formatting
76    cmd.arg("-recursive");
77
78    // If a specific file is provided, use it
79    if let Some(file_path) = file {
80        cmd.arg(file_path);
81    }
82
83    cmd.current_dir(project_dir);
84
85    let output = cmd.output()?;
86
87    let stdout = String::from_utf8_lossy(&output.stdout);
88    let stderr = String::from_utf8_lossy(&output.stderr);
89
90    // Parse the output
91    let mut file_results = Vec::new();
92    let mut files_formatted = 0;
93
94    // stdout contains list of formatted/unformatted files
95    for line in stdout.lines() {
96        let line = line.trim();
97        if line.is_empty() {
98            continue;
99        }
100
101        // terraform fmt -list=true outputs filenames that were/would be formatted
102        file_results.push(FileFormatResult {
103            file: line.to_string(),
104            formatted: true,
105            diff: None,
106        });
107        files_formatted += 1;
108    }
109
110    // If we're doing a diff check, parse the diff output
111    if show_diff && !stderr.is_empty() {
112        // Diffs are written to stdout when using -diff
113        if let Some(last_result) = file_results.last_mut() {
114            last_result.diff = Some(stderr.to_string());
115        }
116    }
117
118    // Count unchanged files by listing all .tf files
119    let all_tf_files = count_tf_files(project_dir);
120    let files_unchanged = all_tf_files.saturating_sub(files_formatted);
121
122    let success = output.status.success() || (!check_only && output.status.code() == Some(0));
123
124    let message = if check_only {
125        if output.status.success() {
126            "All files are properly formatted".to_string()
127        } else {
128            format!("{} files need formatting", files_formatted)
129        }
130    } else if files_formatted > 0 {
131        format!("Formatted {} files", files_formatted)
132    } else {
133        "No files needed formatting".to_string()
134    };
135
136    Ok(FormatResult {
137        success,
138        files_checked: all_tf_files as i32,
139        files_formatted: files_formatted as i32,
140        files_unchanged: files_unchanged as i32,
141        file_results,
142        message,
143    })
144}
145
146/// Count .tf files in a directory (recursive)
147fn count_tf_files(dir: &Path) -> usize {
148    let mut count = 0;
149
150    if let Ok(entries) = std::fs::read_dir(dir) {
151        for entry in entries.flatten() {
152            let path = entry.path();
153            if path.is_dir() {
154                // Skip hidden directories and common non-terraform directories
155                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
156                    if name.starts_with('.') || name == "node_modules" || name == "vendor" {
157                        continue;
158                    }
159                }
160                count += count_tf_files(&path);
161            } else if path.is_file() {
162                if let Some(ext) = path.extension() {
163                    if ext == "tf" {
164                        count += 1;
165                    }
166                }
167            }
168        }
169    }
170
171    count
172}
173
174/// Get format style recommendations
175#[allow(dead_code)]
176pub fn get_format_recommendations() -> Vec<String> {
177    vec![
178        "Use 2-space indentation for nested blocks".to_string(),
179        "Align equals signs in attribute assignments within a block".to_string(),
180        "Use lowercase for resource types and attribute names".to_string(),
181        "Place the opening brace on the same line as the block header".to_string(),
182        "Use blank lines to separate logical groups of attributes".to_string(),
183        "Order meta-arguments (count, for_each, lifecycle) before resource-specific arguments"
184            .to_string(),
185        "Keep line length under 120 characters for readability".to_string(),
186    ]
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use tempfile::TempDir;
194
195    #[test]
196    fn test_count_tf_files() {
197        let temp_dir = TempDir::new().unwrap();
198
199        // Create some .tf files
200        fs::write(temp_dir.path().join("main.tf"), "").unwrap();
201        fs::write(temp_dir.path().join("variables.tf"), "").unwrap();
202        fs::write(temp_dir.path().join("other.txt"), "").unwrap();
203
204        let count = count_tf_files(temp_dir.path());
205        assert_eq!(count, 2);
206    }
207
208    #[test]
209    fn test_format_recommendations() {
210        let recommendations = get_format_recommendations();
211        assert!(!recommendations.is_empty());
212        assert!(recommendations.iter().any(|r| r.contains("indentation")));
213    }
214}