Skip to main content

lean_ctx_patterns/
deploy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared compressor for edge/PaaS *deploy* commands (vercel, fly, wrangler,
4//! skaffold, supabase).
5//!
6//! These tools emit long build/upload logs and end with the few lines that
7//! matter: the deployment URL, the release/version, and any error. The
8//! algorithm is deliberately conservative — it NEVER drops a line containing a
9//! URL or an error indicator — so the agent always receives the deploy target.
10//! Build noise (compiling, bundling, progress, layer hashes) is dropped.
11//!
12//! Subcommand gating lives here: only deploy-style subcommands are handled;
13//! list/status/inspect/dev/login return `None` so they keep their existing
14//! (verbatim/passthrough/generic) treatment.
15
16use crate::ansi::strip_ansi;
17
18pub fn compress(command: &str, output: &str) -> Option<String> {
19    let c = command.trim();
20    let (tool, rest) = split_tool(c)?;
21    if !is_deploy_sub(tool, rest.trim_start()) {
22        return None;
23    }
24    Some(compress_deploy(output))
25}
26
27fn split_tool(c: &str) -> Option<(&str, &str)> {
28    for tool in [
29        "vercel", "flyctl", "fly", "wrangler", "skaffold", "supabase",
30    ] {
31        if c == tool {
32            return Some((tool, ""));
33        }
34        if let Some(rest) = c.strip_prefix(tool)
35            && rest.starts_with(' ')
36        {
37            return Some((tool, rest));
38        }
39    }
40    None
41}
42
43fn is_deploy_sub(tool: &str, rest: &str) -> bool {
44    let first = rest.split_whitespace().next().unwrap_or("");
45    match tool {
46        // bare `vercel`/`vercel --prod` deploys; explicit deploy/build too.
47        "vercel" => {
48            rest.is_empty() || first.starts_with('-') || matches!(first, "deploy" | "build")
49        }
50        "fly" | "flyctl" => matches!(first, "deploy" | "launch"),
51        "wrangler" => first == "deploy" || first == "publish" || rest.starts_with("pages deploy"),
52        "skaffold" => matches!(first, "run" | "build" | "deploy" | "apply"),
53        "supabase" => {
54            rest.starts_with("db push")
55                || rest.starts_with("db reset")
56                || rest.starts_with("migration up")
57                || rest.starts_with("migration repair")
58                || rest.starts_with("functions deploy")
59        }
60        _ => false,
61    }
62}
63
64fn compress_deploy(output: &str) -> String {
65    let mut kept: Vec<String> = Vec::new();
66    for raw in output.lines() {
67        let line = strip_ansi(raw);
68        let t = line.trim();
69        if t.is_empty() {
70            continue;
71        }
72        if is_signal(t) && kept.last().map(String::as_str) != Some(t) {
73            kept.push(t.to_string());
74        }
75    }
76    if kept.is_empty() {
77        return "deploy: ok".to_string();
78    }
79    // Deploy URL + final status live at the tail; keep the tail when long.
80    let max = 30;
81    if kept.len() <= max {
82        return kept.join("\n");
83    }
84    let tail = &kept[kept.len() - max..];
85    format!(
86        "... (+{} earlier lines)\n{}",
87        kept.len() - max,
88        tail.join("\n")
89    )
90}
91
92const MARKERS: &[&str] = &[
93    "deployed",
94    "deploy complete",
95    "deployment complete",
96    "published",
97    "released",
98    "release v",
99    "current deployment",
100    "visit your",
101    "image:",
102    "uploaded",
103    "total upload",
104    "build completed",
105    "finished supabase",
106    "deployments are now",
107    "no changes",
108    "skipped",
109    "success",
110    "✓",
111    "✅",
112    "live",
113    "applied migration",
114    "applying migration",
115];
116
117fn is_signal(t: &str) -> bool {
118    if t.contains("http://") || t.contains("https://") {
119        return true;
120    }
121    let tl = t.to_ascii_lowercase();
122    if tl.contains("error")
123        || tl.contains("failed")
124        || tl.contains("panic")
125        || tl.contains("warning")
126        || t.contains('✘')
127        || t.contains('✖')
128    {
129        return true;
130    }
131    MARKERS.iter().any(|m| tl.contains(m))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn vercel_keeps_url_drops_build_noise() {
140        let out = "Vercel CLI 33.0.0\nInspect: https://vercel.com/org/proj/abc [2s]\nInstalling dependencies...\nadded 420 packages in 12s\nBuilding...\nCompiling pages\nProduction: https://my-app.vercel.app [45s]\n";
141        let r = compress("vercel deploy --prod", out).unwrap();
142        assert!(
143            r.contains("https://my-app.vercel.app"),
144            "keeps prod url: {r}"
145        );
146        assert!(
147            r.contains("https://vercel.com/org/proj/abc"),
148            "keeps inspect url: {r}"
149        );
150        assert!(
151            !r.contains("added 420 packages"),
152            "drops install noise: {r}"
153        );
154        assert!(!r.contains("Compiling pages"), "drops build noise: {r}");
155    }
156
157    #[test]
158    fn wrangler_keeps_published_url() {
159        let out = "Total Upload: 1.2 MiB / gzip: 0.4 MiB\nUploaded my-worker (3.5 sec)\nPublished my-worker (1.2 sec)\n  https://my-worker.example.workers.dev\nCurrent Deployment ID: abc-123";
160        let r = compress("wrangler deploy", out).unwrap();
161        assert!(r.contains("https://my-worker.example.workers.dev"), "{r}");
162        assert!(r.contains("Published my-worker"), "{r}");
163    }
164
165    #[test]
166    fn fly_deploy_keeps_status_and_errors() {
167        let out = "==> Building image\n--> Building image done\nWatch your deployment at https://fly.io/apps/myapp/monitoring\n   1 desired, 1 placed, 0 healthy\nError: failed to deploy: smoke checks failed";
168        let r = compress("fly deploy", out).unwrap();
169        assert!(r.contains("Error: failed to deploy"), "keeps error: {r}");
170        assert!(r.contains("https://fly.io/apps/myapp"), "keeps url: {r}");
171    }
172
173    #[test]
174    fn non_deploy_subcommands_return_none() {
175        assert!(compress("vercel ls", "deployment list").is_none());
176        assert!(compress("fly status", "status table").is_none());
177        assert!(compress("wrangler dev", "dev server").is_none());
178        assert!(compress("supabase start", "API URL: http://localhost").is_none());
179    }
180
181    #[test]
182    fn empty_is_ok() {
183        assert_eq!(compress("vercel deploy", "").unwrap(), "deploy: ok");
184    }
185}