lean_ctx_patterns/
spark.rs1use crate::ansi::strip_ansi;
14use std::collections::HashSet;
15
16pub fn compress(_cmd: &str, output: &str) -> Option<String> {
17 let trimmed = output.trim();
18 if trimmed.is_empty() {
19 return Some("spark: ok".to_string());
20 }
21
22 let mut jobs: Vec<String> = Vec::new();
23 let mut warnings: Vec<String> = Vec::new();
24 let mut warn_seen: HashSet<String> = HashSet::new();
25 let mut errors: Vec<String> = Vec::new();
26 let mut app_output: Vec<String> = Vec::new();
27 let mut saw_log = false;
28
29 for raw in trimmed.lines() {
30 let stripped = strip_ansi(raw);
31 let line = stripped.trim();
32 if line.is_empty() {
33 continue;
34 }
35
36 if let Some((level, rest)) = parse_log(line) {
37 saw_log = true;
38 match level {
39 "INFO" => {
40 if rest.contains("Job ") && rest.contains("finished") {
41 jobs.push(rest.to_string());
42 }
43 }
44 "WARN" => {
45 if warn_seen.insert(normalize(rest)) {
46 warnings.push(rest.to_string());
47 }
48 }
49 "ERROR" => errors.push(rest.to_string()),
50 _ => {}
51 }
52 } else if is_exception(line) {
53 errors.push(line.to_string());
54 } else {
55 app_output.push(line.to_string());
57 }
58 }
59
60 if !saw_log && jobs.is_empty() && errors.is_empty() {
61 return Some(fallback(trimmed));
62 }
63
64 let mut parts = vec![format!(
65 "spark: {} job(s), {} warning(s), {} error(s)",
66 jobs.len(),
67 warnings.len(),
68 errors.len()
69 )];
70 push_capped(&mut parts, &jobs, 10, "more jobs");
71 push_capped(&mut parts, &warnings, 5, "more warnings");
72 push_capped(&mut parts, &errors, 10, "more errors");
73 push_capped(&mut parts, &app_output, 20, "more output lines");
74 Some(parts.join("\n"))
75}
76
77fn parse_log(line: &str) -> Option<(&str, &str)> {
79 let mut it = line.splitn(4, ' ');
80 let date = it.next()?;
81 let time = it.next()?;
82 let level = it.next()?;
83 let rest = it.next().unwrap_or("");
84 if looks_like_date(date) && looks_like_time(time) && is_level(level) {
85 Some((level, rest))
86 } else {
87 None
88 }
89}
90
91fn looks_like_date(s: &str) -> bool {
92 let parts: Vec<&str> = s.split('/').collect();
93 parts.len() == 3 && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
94}
95
96fn looks_like_time(s: &str) -> bool {
97 let parts: Vec<&str> = s.split(':').collect();
98 parts.len() == 3 && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
99}
100
101fn is_level(s: &str) -> bool {
102 matches!(s, "INFO" | "WARN" | "ERROR" | "DEBUG" | "TRACE")
103}
104
105fn is_exception(line: &str) -> bool {
106 line.contains("Exception") || line.starts_with("Caused by:") || line.contains("Error:")
107}
108
109fn normalize(s: &str) -> String {
111 s.chars().filter(|c| !c.is_ascii_digit()).collect()
112}
113
114fn push_capped(parts: &mut Vec<String>, items: &[String], cap: usize, label: &str) {
115 for item in items.iter().take(cap) {
116 parts.push(format!(" {item}"));
117 }
118 if items.len() > cap {
119 parts.push(format!(" ... +{} {label}", items.len() - cap));
120 }
121}
122
123fn fallback(text: &str) -> String {
124 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
125 let n = lines.len().min(8);
126 let mut s = lines[..n].join("\n");
127 if lines.len() > n {
128 s.push_str(&format!("\n... (+{} lines)", lines.len() - n));
129 }
130 s
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 const LOG: &str = "23/01/01 12:00:00 INFO SparkContext: Running Spark version 3.4.0\n23/01/01 12:00:01 INFO ResourceUtils: No custom resources configured\n23/01/01 12:00:02 INFO Utils: Successfully started service\n23/01/01 12:00:03 WARN NativeCodeLoader: Unable to load native-hadoop\n23/01/01 12:00:10 INFO DAGScheduler: Job 0 finished: collect at Main.scala:20, took 5.123 s\n23/01/01 12:00:15 ERROR Executor: Exception in task 0.0 in stage 1.0\n";
138
139 #[test]
140 fn drops_info_keeps_job_warn_error() {
141 let r = compress("spark-submit app.py", LOG).unwrap();
142 assert!(r.contains("1 job(s), 1 warning(s), 1 error(s)"), "{r}");
143 assert!(r.contains("Job 0 finished"), "{r}");
144 assert!(r.contains("Executor: Exception"), "{r}");
145 assert!(!r.contains("ResourceUtils"), "drops info noise: {r}");
146 assert!(!r.contains("23/01/01"), "drops timestamps: {r}");
147 }
148
149 #[test]
150 fn keeps_application_stdout() {
151 let log = "23/01/01 12:00:00 INFO SparkContext: Running Spark version 3.4.0\n23/01/01 12:00:01 INFO ResourceUtils: No custom resources\n23/01/01 12:00:10 INFO DAGScheduler: Job 0 finished: collect, took 5.1 s\nResult: total words = 184273\n23/01/01 12:00:11 INFO SparkContext: Successfully stopped";
152 let r = compress("spark-submit app.py", log).unwrap();
153 assert!(
154 r.contains("Result: total words = 184273"),
155 "keeps app output: {r}"
156 );
157 assert!(!r.contains("ResourceUtils"), "still drops info noise: {r}");
158 }
159
160 #[test]
161 fn shorter_than_input() {
162 let r = compress("spark-submit app.py", LOG).unwrap();
163 assert!(r.len() < LOG.len());
164 }
165
166 #[test]
167 fn empty_is_ok() {
168 assert_eq!(compress("spark-submit app.py", "").unwrap(), "spark: ok");
169 }
170}