Skip to main content

lean_ctx/core/patterns/
terraform.rs

1use 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}