lean_ctx/core/patterns/
poetry.rs1use regex::Regex;
2use std::sync::OnceLock;
3
4static UV_INSTALLED_LINE: OnceLock<Regex> = OnceLock::new();
5static UV_RESOLVED: OnceLock<Regex> = OnceLock::new();
6static POETRY_INSTALLING: OnceLock<Regex> = OnceLock::new();
7static POETRY_UPDATING: OnceLock<Regex> = OnceLock::new();
8static PIP_STYLE_SUCCESS: OnceLock<Regex> = OnceLock::new();
9static PERCENT_BAR: OnceLock<Regex> = OnceLock::new();
10
11fn uv_installed_line_re() -> &'static Regex {
12 UV_INSTALLED_LINE.get_or_init(|| Regex::new(r"^\s*\+\s+(\S+)").unwrap())
13}
14fn uv_resolved_re() -> &'static Regex {
15 UV_RESOLVED
16 .get_or_init(|| Regex::new(r"(?i)^(Resolved|Prepared|Installed|Audited)\s+").unwrap())
17}
18fn poetry_installing_re() -> &'static Regex {
19 POETRY_INSTALLING
20 .get_or_init(|| Regex::new(r"(?i)^\s*-\s+Installing\s+(\S+)\s+\(([^)]+)\)").unwrap())
21}
22fn poetry_updating_re() -> &'static Regex {
23 POETRY_UPDATING
24 .get_or_init(|| Regex::new(r"(?i)^\s*-\s+Updating\s+(\S+)\s+\(([^)]+)\)").unwrap())
25}
26fn pip_style_success_re() -> &'static Regex {
27 PIP_STYLE_SUCCESS.get_or_init(|| Regex::new(r"(?i)Successfully installed\s+(.+)").unwrap())
28}
29fn percent_bar_re() -> &'static Regex {
30 PERCENT_BAR.get_or_init(|| Regex::new(r"\d+%\|").unwrap())
31}
32
33pub fn compress(command: &str, output: &str) -> Option<String> {
34 let cl = command.trim().to_ascii_lowercase();
35 if cl.starts_with("poetry ") {
36 let sub = cl.split_whitespace().nth(1).unwrap_or("");
37 return match sub {
38 "install" => Some(compress_poetry(output, false)),
39 "update" => Some(compress_poetry(output, true)),
40 "add" => Some(compress_poetry(output, false)),
41 _ => None,
42 };
43 }
44 let parts: Vec<&str> = cl.split_whitespace().collect();
45 if parts.len() >= 2 && parts[0] == "uv" && parts[1] == "sync" {
46 return Some(compress_uv(output));
47 }
48 if parts.len() >= 3 && parts[0] == "uv" && parts[1] == "pip" && parts[2] == "install" {
49 return Some(compress_uv(output));
50 }
51 if cl.starts_with("conda ") || cl.starts_with("mamba ") {
52 let sub = parts.get(1).copied().unwrap_or("");
53 return match sub {
54 "install" | "create" | "update" | "remove" => Some(compress_conda(output)),
55 "list" => Some(compress_conda_list(output)),
56 "info" => Some(compress_conda_info(output)),
57 _ => None,
58 };
59 }
60 if cl.starts_with("pipx ") {
61 return Some(compress_pipx(output));
62 }
63 None
64}
65
66fn is_download_noise(line: &str) -> bool {
67 let t = line.trim();
68 let tl = t.to_ascii_lowercase();
69 if tl.contains("downloading ")
70 || tl.starts_with("downloading [")
71 || tl.contains("kiB/s")
72 || tl.contains("kib/s")
73 || tl.contains("mib/s")
74 || tl.contains("%") && (tl.contains("eta") || tl.contains('|') || tl.contains("of "))
75 {
76 return true;
77 }
78 if tl.starts_with("progress ") && tl.contains('/') {
79 return true;
80 }
81 if percent_bar_re().is_match(t) {
82 return true;
83 }
84 false
85}
86
87fn compress_poetry(output: &str, prefer_update: bool) -> String {
88 let mut packages = Vec::new();
89 let mut errors = Vec::new();
90
91 for line in output.lines() {
92 let t = line.trim_end();
93 if t.trim().is_empty() || is_download_noise(t) {
94 continue;
95 }
96 let trim = t.trim();
97 let tl = trim.to_ascii_lowercase();
98
99 if prefer_update {
100 if let Some(caps) = poetry_updating_re().captures(trim) {
101 packages.push(format!("{} {}", &caps[1], &caps[2]));
102 continue;
103 }
104 }
105 if let Some(caps) = poetry_installing_re().captures(trim) {
106 packages.push(format!("{} {}", &caps[1], &caps[2]));
107 continue;
108 }
109 if !prefer_update {
110 if let Some(caps) = poetry_updating_re().captures(trim) {
111 packages.push(format!("{} {}", &caps[1], &caps[2]));
112 continue;
113 }
114 }
115
116 if tl.contains("error")
117 && (tl.contains("because") || tl.contains("could not") || tl.contains("failed"))
118 {
119 errors.push(trim.to_string());
120 }
121 if tl.starts_with("solverproblemerror") || tl.contains("version solving failed") {
122 errors.push(trim.to_string());
123 }
124 }
125
126 let mut parts = Vec::new();
127 if !packages.is_empty() {
128 parts.push(format!("{} package(s):", packages.len()));
129 parts.extend(packages.into_iter().map(|p| format!(" {p}")));
130 }
131 if !errors.is_empty() {
132 parts.push(format!("{} error line(s):", errors.len()));
133 parts.extend(errors.into_iter().take(15).map(|e| format!(" {e}")));
134 }
135
136 if parts.is_empty() {
137 fallback_compact(output)
138 } else {
139 parts.join("\n")
140 }
141}
142
143fn compress_uv(output: &str) -> String {
144 let mut summary = Vec::new();
145 let mut installed = Vec::new();
146 let mut errors = Vec::new();
147
148 for line in output.lines() {
149 let t = line.trim_end();
150 if t.trim().is_empty() || is_download_noise(t) {
151 continue;
152 }
153 let trim = t.trim();
154 let tl = trim.to_ascii_lowercase();
155
156 if uv_resolved_re().is_match(trim) {
157 summary.push(trim.to_string());
158 continue;
159 }
160 if let Some(caps) = uv_installed_line_re().captures(trim) {
161 installed.push(caps[1].to_string());
162 continue;
163 }
164 if let Some(caps) = pip_style_success_re().captures(trim) {
165 let pkgs: Vec<&str> = caps[1].split_whitespace().collect();
166 summary.push(format!("Successfully installed {} packages", pkgs.len()));
167 for p in pkgs.into_iter().take(30) {
168 installed.push(p.to_string());
169 }
170 continue;
171 }
172
173 if tl.contains("error:")
174 || tl.starts_with("error:")
175 || tl.contains("failed to")
176 || tl.contains("resolution failed")
177 {
178 errors.push(trim.to_string());
179 }
180 }
181
182 let mut parts = Vec::new();
183 parts.extend(summary);
184 if !installed.is_empty() {
185 parts.push(format!("+ {} package(s):", installed.len()));
186 for p in installed.into_iter().take(40) {
187 parts.push(format!(" {p}"));
188 }
189 }
190 if !errors.is_empty() {
191 parts.push(format!("{} error line(s):", errors.len()));
192 parts.extend(errors.into_iter().take(15).map(|e| format!(" {e}")));
193 }
194
195 if parts.is_empty() {
196 fallback_compact(output)
197 } else {
198 parts.join("\n")
199 }
200}
201
202fn compress_conda(output: &str) -> String {
203 let mut packages = Vec::new();
204 let mut errors = Vec::new();
205 let mut action = String::new();
206
207 for line in output.lines() {
208 let t = line.trim();
209 if t.is_empty() || is_download_noise(t) {
210 continue;
211 }
212 let tl = t.to_ascii_lowercase();
213
214 if tl.starts_with("the following packages will be")
215 || tl.starts_with("the following new packages")
216 {
217 action = t.to_string();
218 continue;
219 }
220 if t.starts_with(" ") && t.contains("::") {
221 packages.push(t.trim().to_string());
222 continue;
223 }
224 if t.starts_with(" ") && !t.starts_with(" ") && packages.is_empty() {
225 let name = t.split_whitespace().next().unwrap_or(t);
226 packages.push(name.to_string());
227 continue;
228 }
229 if tl.contains("error")
230 || tl.contains("conflictingerror")
231 || tl.contains("unsatisfiableerror")
232 {
233 errors.push(t.to_string());
234 }
235 }
236
237 let mut parts = Vec::new();
238 if !action.is_empty() {
239 parts.push(action);
240 }
241 if !packages.is_empty() {
242 parts.push(format!("{} package(s)", packages.len()));
243 for p in packages.iter().take(20) {
244 parts.push(format!(" {p}"));
245 }
246 if packages.len() > 20 {
247 parts.push(format!(" ... +{} more", packages.len() - 20));
248 }
249 }
250 if !errors.is_empty() {
251 parts.push(format!("{} error(s):", errors.len()));
252 parts.extend(errors.into_iter().take(10).map(|e| format!(" {e}")));
253 }
254
255 if parts.is_empty() {
256 fallback_compact(output)
257 } else {
258 parts.join("\n")
259 }
260}
261
262fn compress_conda_list(output: &str) -> String {
263 let lines: Vec<&str> = output
264 .lines()
265 .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
266 .collect();
267 if lines.is_empty() {
268 return "no packages".to_string();
269 }
270 if lines.len() <= 10 {
271 return lines.join("\n");
272 }
273 format!(
274 "{} packages installed\n{}\n... +{} more",
275 lines.len(),
276 lines[..10].join("\n"),
277 lines.len() - 10
278 )
279}
280
281fn compress_conda_info(output: &str) -> String {
282 let important = [
283 "active environment",
284 "conda version",
285 "platform",
286 "python version",
287 ];
288 let mut info = Vec::new();
289 for line in output.lines() {
290 let trimmed = line.trim();
291 for key in &important {
292 if trimmed.to_lowercase().starts_with(key) {
293 info.push(trimmed.to_string());
294 break;
295 }
296 }
297 }
298 if info.is_empty() {
299 fallback_compact(output)
300 } else {
301 info.join("\n")
302 }
303}
304
305fn compress_pipx(output: &str) -> String {
306 let mut parts = Vec::new();
307 for line in output.lines() {
308 let t = line.trim();
309 if t.is_empty() || is_download_noise(t) {
310 continue;
311 }
312 let tl = t.to_ascii_lowercase();
313 if tl.contains("installed package")
314 || tl.contains("done!")
315 || tl.contains("these apps are now")
316 {
317 parts.push(t.to_string());
318 }
319 }
320 if parts.is_empty() {
321 fallback_compact(output)
322 } else {
323 parts.join("\n")
324 }
325}
326
327fn fallback_compact(output: &str) -> String {
328 let lines: Vec<&str> = output
329 .lines()
330 .map(str::trim_end)
331 .filter(|l| !l.trim().is_empty() && !is_download_noise(l))
332 .collect();
333 if lines.is_empty() {
334 return "ok".to_string();
335 }
336 let max = 12usize;
337 if lines.len() <= max {
338 return lines.join("\n");
339 }
340 format!(
341 "{}\n... ({} more lines)",
342 lines[..max].join("\n"),
343 lines.len() - max
344 )
345}