Skip to main content

lean_ctx_patterns/
pulumi.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pulumi (`pulumi up`/`preview`/`destroy`) output compression.
4//!
5//! Pulumi prints a per-resource event tree (one row per resource) followed by
6//! an `Outputs:` block, a `Resources:` summary and a `Duration:` line. The tree
7//! is noise; the outputs (stack exports — real data), the resource counts, the
8//! duration and any diagnostics are signal. We keep the latter and drop the
9//! tree.
10
11macro_rules! static_regex {
12    ($pattern:expr_2021) => {{
13        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
14        RE.get_or_init(|| {
15            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
16        })
17    }};
18}
19
20/// Resource-count summary rows: `+ 5 to create`, `~ 2 updated`, `3 unchanged`.
21fn summary_re() -> &'static regex::Regex {
22    static_regex!(
23        r"^[+~\-]?\s*\d+\s+(to create|to update|to delete|to replace|created|updated|deleted|replaced|unchanged|changed)\b"
24    )
25}
26
27pub fn compress(command: &str, output: &str) -> Option<String> {
28    let c = command.trim();
29    let sub = c
30        .strip_prefix("pulumi")
31        .map_or("", str::trim_start)
32        .split_whitespace()
33        .next()
34        .unwrap_or("");
35    match sub {
36        "up" | "update" | "preview" | "destroy" | "refresh" => Some(compress_update(output)),
37        _ => Some(compress_generic(output)),
38    }
39}
40
41fn compress_update(output: &str) -> String {
42    let mut kept: Vec<String> = Vec::new();
43    let mut in_outputs = false;
44
45    for raw in output.lines() {
46        let t = raw.trim();
47        if t.is_empty() {
48            in_outputs = false;
49            continue;
50        }
51        let tl = t.to_ascii_lowercase();
52
53        if t == "Outputs:" {
54            in_outputs = true;
55            kept.push(t.to_string());
56            continue;
57        }
58        if in_outputs {
59            // Output kv pairs are indented; the block ends at a blank line or a
60            // following section header (handled above / below).
61            if t == "Resources:" || tl.starts_with("duration:") {
62                in_outputs = false;
63            } else {
64                kept.push(format!("  {t}"));
65                continue;
66            }
67        }
68
69        if t == "Resources:" || tl.starts_with("duration:") {
70            kept.push(t.to_string());
71            continue;
72        }
73        if summary_re().is_match(t) {
74            kept.push(t.to_string());
75            continue;
76        }
77        if tl.starts_with("updating (")
78            || tl.starts_with("previewing update (")
79            || tl.starts_with("destroying (")
80        {
81            kept.push(t.to_string());
82            continue;
83        }
84        if tl.contains("error:")
85            || tl.starts_with("error")
86            || tl.starts_with("diagnostics:")
87            || tl.contains("failed")
88            || tl.contains("panic:")
89        {
90            kept.push(t.to_string());
91        }
92    }
93
94    if kept.is_empty() {
95        return compress_generic(output);
96    }
97    kept.join("\n")
98}
99
100fn compress_generic(output: &str) -> String {
101    let lines: Vec<&str> = output
102        .lines()
103        .map(str::trim_end)
104        .filter(|l| !l.trim().is_empty())
105        .collect();
106    if lines.is_empty() {
107        return "pulumi: ok".to_string();
108    }
109    let max = 15;
110    if lines.len() <= max {
111        return lines.join("\n");
112    }
113    format!(
114        "{}\n... (+{} lines)",
115        lines[..max].join("\n"),
116        lines.len() - max
117    )
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    const UP: &str = "Updating (dev):\n     Type                 Name        Status\n +   pulumi:pulumi:Stack  proj-dev    created\n +   ├─ aws:s3:Bucket     my-bucket   created\n ~   └─ aws:s3:BucketPolicy pol       updated\n\nOutputs:\n    bucketName: \"my-bucket-abc123\"\n    url       : \"https://my-bucket.s3.amazonaws.com\"\n\nResources:\n    + 5 created\n    ~ 2 updated\n    10 unchanged\n\nDuration: 35s\n";
125
126    #[test]
127    fn keeps_outputs_summary_duration_drops_tree() {
128        let r = compress("pulumi up", UP).unwrap();
129        assert!(
130            r.contains("url       : \"https://my-bucket"),
131            "keeps outputs: {r}"
132        );
133        assert!(r.contains("+ 5 created"), "keeps summary: {r}");
134        assert!(r.contains("Duration: 35s"), "keeps duration: {r}");
135        assert!(
136            !r.contains("pulumi:pulumi:Stack"),
137            "drops resource tree: {r}"
138        );
139        assert!(!r.contains("aws:s3:Bucket "), "drops resource tree: {r}");
140    }
141
142    #[test]
143    fn keeps_errors() {
144        let out = "Updating (dev):\n +   aws:s3:Bucket b created\nDiagnostics:\n  aws:s3:Bucket (b):\n    error: creating S3 Bucket: BucketAlreadyExists";
145        let r = compress("pulumi up", out).unwrap();
146        assert!(r.contains("error: creating S3 Bucket"), "{r}");
147    }
148
149    #[test]
150    fn shorter_than_input() {
151        let r = compress("pulumi up", UP).unwrap();
152        assert!(r.len() < UP.len(), "compressed shorter");
153    }
154}