lean_ctx/core/patterns/
deploy.rs1use 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 "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 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}