lean_ctx/core/patterns/
terraform.rs1use regex::Regex;
2use std::sync::OnceLock;
3
4static PLAN_SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
5static APPLY_SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
6static INSTALLED_PROVIDER_RE: OnceLock<Regex> = OnceLock::new();
7static PROVIDER_VERSION_RE: OnceLock<Regex> = OnceLock::new();
8
9fn plan_summary_re() -> &'static Regex {
10 PLAN_SUMMARY_RE.get_or_init(|| {
11 Regex::new(r"Plan:\s*(\d+)\s+to add,\s*(\d+)\s+to change,\s*(\d+)\s+to destroy").unwrap()
12 })
13}
14
15fn apply_summary_re() -> &'static Regex {
16 APPLY_SUMMARY_RE.get_or_init(|| {
17 Regex::new(
18 r"Apply complete!\s*Resources:\s*(\d+)\s+added,\s*(\d+)\s+changed,\s*(\d+)\s+destroyed",
19 )
20 .unwrap()
21 })
22}
23
24fn installed_provider_re() -> &'static Regex {
25 INSTALLED_PROVIDER_RE
26 .get_or_init(|| Regex::new(r"-\s*Installed\s+([^\s]+)\s+v([0-9][^\s]*)").unwrap())
27}
28
29fn provider_version_re() -> &'static Regex {
30 PROVIDER_VERSION_RE
31 .get_or_init(|| Regex::new(r"\*\s*provider\[([^\]]+)\]\s+([0-9][^\s]*)").unwrap())
32}
33
34fn is_provider_init_noise(line: &str) -> bool {
35 let t = line.trim_start();
36 let tl = t.to_ascii_lowercase();
37 tl.contains("initializing provider plugins")
38 || tl.contains("initializing the backend")
39 || tl.contains("finding ")
40 && (tl.contains("versions matching") || tl.contains("version of"))
41 || tl.starts_with("- finding ")
42 || tl.starts_with("- installing ")
43 || tl.contains("terraform init") && tl.contains("upgrade")
44 || tl.starts_with("╷")
45 || tl.starts_with("╵")
46 || tl.starts_with("│")
47}
48
49pub fn compress(command: &str, output: &str) -> Option<String> {
50 let c = command.trim();
51 let prefix = if c == "terraform" || c.starts_with("terraform ") {
52 "terraform"
53 } else if c == "tofu" || c.starts_with("tofu ") {
54 "tofu"
55 } else {
56 return None;
57 };
58 let sub = c.strip_prefix(prefix).map(str::trim_start).unwrap_or("");
59 let sub_cmd = sub.split_whitespace().next().unwrap_or("");
60
61 match sub_cmd {
62 "plan" => Some(compress_plan(output)),
63 "apply" => Some(compress_apply(output)),
64 "init" => Some(compress_init(output)),
65 "validate" => Some(compress_validate(output)),
66 _ => Some(compress_generic(output)),
67 }
68}
69
70fn compress_plan(output: &str) -> String {
71 let mut kept = Vec::new();
72
73 for line in output.lines() {
74 if is_provider_init_noise(line) {
75 continue;
76 }
77 let tl = line.trim_start();
78 if tl.starts_with("- Installed ") || tl.starts_with("- Installing ") {
79 continue;
80 }
81
82 if let Some(caps) = plan_summary_re().captures(line) {
83 let add = caps.get(1).map(|m| m.as_str()).unwrap_or("0");
84 let chg = caps.get(2).map(|m| m.as_str()).unwrap_or("0");
85 let des = caps.get(3).map(|m| m.as_str()).unwrap_or("0");
86 kept.push(format!("+ {add} added, ~ {chg} changed, - {des} destroyed"));
87 continue;
88 }
89
90 let l = line.to_ascii_lowercase();
91 if l.contains("no changes.") || l.contains("infrastructure matches the configuration") {
92 kept.push("No changes.".to_string());
93 continue;
94 }
95
96 let is_diag = tl.contains('╷')
97 || tl.contains('│')
98 || tl.contains('╵')
99 || l.contains("error:")
100 || (l.contains("error ")
101 && (l.contains("terraform") || l.contains("plan") || l.contains("provider")))
102 || l.contains("warning:")
103 || l.contains("warning ");
104 if is_diag {
105 kept.push(line.trim().to_string());
106 }
107 }
108
109 if kept.is_empty() {
110 "terraform plan (no summary parsed)".to_string()
111 } else {
112 kept.join("\n")
113 }
114}
115
116fn compress_apply(output: &str) -> String {
117 let mut results = Vec::new();
118 let mut errors = Vec::new();
119
120 for line in output.lines() {
121 if is_provider_init_noise(line) {
122 continue;
123 }
124 let tl = line.trim();
125 if tl.is_empty() {
126 continue;
127 }
128
129 if let Some(caps) = apply_summary_re().captures(line) {
130 let a = caps.get(1).map(|m| m.as_str()).unwrap_or("0");
131 let c = caps.get(2).map(|m| m.as_str()).unwrap_or("0");
132 let d = caps.get(3).map(|m| m.as_str()).unwrap_or("0");
133 results.push(format!(
134 "Apply complete: +{a} added, ~{c} changed, -{d} destroyed"
135 ));
136 continue;
137 }
138
139 let ll = tl.to_ascii_lowercase();
140 if ll.contains("error")
141 && (ll.contains("apply") || ll.contains("terraform") || tl.contains('╷'))
142 {
143 errors.push(tl.to_string());
144 } else if ll.starts_with("creation complete")
145 || ll.starts_with("modification complete")
146 || ll.starts_with("destruction complete")
147 || ll.starts_with("destroy complete")
148 {
149 results.push(tl.to_string());
150 }
151 }
152
153 let mut out = Vec::new();
154 if !results.is_empty() {
155 out.push(results.join("\n"));
156 }
157 if !errors.is_empty() {
158 out.push(format!("errors:\n{}", errors.join("\n")));
159 }
160 if out.is_empty() {
161 "terraform apply (no summary parsed)".to_string()
162 } else {
163 out.join("\n\n")
164 }
165}
166
167fn compress_init(output: &str) -> String {
168 let mut providers: Vec<String> = Vec::new();
169 let mut success = false;
170
171 for line in output.lines() {
172 let tl = line.trim();
173 if tl.is_empty() {
174 continue;
175 }
176 let ll = tl.to_ascii_lowercase();
177 if ll.contains("terraform has been successfully initialized")
178 || ll.contains("initialization complete")
179 {
180 success = true;
181 }
182 if let Some(caps) = installed_provider_re().captures(tl) {
183 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("?");
184 let ver = caps.get(2).map(|m| m.as_str()).unwrap_or("?");
185 providers.push(format!("{name} v{ver}"));
186 continue;
187 }
188 if let Some(caps) = provider_version_re().captures(tl) {
189 let reg = caps.get(1).map(|m| m.as_str()).unwrap_or("?");
190 let ver = caps.get(2).map(|m| m.as_str()).unwrap_or("?");
191 providers.push(format!("{reg} {ver}"));
192 }
193 }
194
195 let status = if success {
196 "Terraform initialized"
197 } else {
198 "terraform init"
199 };
200
201 if providers.is_empty() {
202 status.to_string()
203 } else {
204 format!("{status}\n{}", providers.join(", "))
205 }
206}
207
208fn compress_validate(output: &str) -> String {
209 let mut errs = Vec::new();
210 for line in output.lines() {
211 let tl = line.trim();
212 if tl.is_empty() {
213 continue;
214 }
215 let ll = tl.to_ascii_lowercase();
216 if ll.contains("success!") && ll.contains("configuration is valid") {
217 return "Success".to_string();
218 }
219 if ll.contains("error") || tl.starts_with('╷') || tl.starts_with('│') {
220 errs.push(tl.to_string());
221 }
222 }
223 if errs.is_empty() {
224 "Success".to_string()
225 } else {
226 errs.join("\n")
227 }
228}
229
230fn compress_generic(output: &str) -> String {
231 let mut lines: Vec<String> = output
232 .lines()
233 .filter(|l| !is_provider_init_noise(l))
234 .map(|l| l.trim().to_string())
235 .filter(|l| !l.is_empty())
236 .collect();
237 if lines.len() > 40 {
238 let n = lines.len();
239 lines = lines.split_off(n - 25);
240 format!("... (truncated)\n{}", lines.join("\n"))
241 } else {
242 lines.join("\n")
243 }
244}