Skip to main content

lean_ctx/core/patterns/
pulumi.rs

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