1use serde::{Deserialize, Serialize};
4use std::path::Path;
5use std::process::Command;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FileFormatResult {
10 pub file: String,
11 pub formatted: bool,
12 pub diff: Option<String>,
13}
14
15#[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
26pub 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
35pub 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
44pub 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
53fn 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 cmd.arg("-list=true");
74
75 cmd.arg("-recursive");
77
78 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 let mut file_results = Vec::new();
92 let mut files_formatted = 0;
93
94 for line in stdout.lines() {
96 let line = line.trim();
97 if line.is_empty() {
98 continue;
99 }
100
101 file_results.push(FileFormatResult {
103 file: line.to_string(),
104 formatted: true,
105 diff: None,
106 });
107 files_formatted += 1;
108 }
109
110 if show_diff && !stderr.is_empty() {
112 if let Some(last_result) = file_results.last_mut() {
114 last_result.diff = Some(stderr.to_string());
115 }
116 }
117
118 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
146fn 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 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#[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 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}