Skip to main content

lean_ctx/core/patterns/
deploy.rs

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