rusty_stack/
benchmark_logs.rs1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5const BENCHMARK_RESULTS_MARKER: &str = "---BENCHMARK_RESULTS_START---";
6
7pub fn benchmark_log_directories() -> Vec<PathBuf> {
10 let mut dirs = Vec::new();
11
12 if let Ok(env_dir) = env::var("MLSTACK_LOG_DIR") {
13 let trimmed = env_dir.trim();
14 if !trimmed.is_empty() {
15 let path = PathBuf::from(trimmed);
16 if let Some(dir) = existing_log_dir(&path) {
17 push_unique(&mut dirs, dir);
18 }
19 }
20 }
21
22 for home in candidate_log_homes() {
23 let path = Path::new(&home).join(".rusty-stack").join("logs");
24 if let Some(dir) = existing_log_dir(&path) {
25 push_unique(&mut dirs, dir);
26 }
27 let claude_tmp = Path::new(&home)
29 .join(".claude")
30 .join("tmp")
31 .join("rusty-stack")
32 .join("logs");
33 if let Some(dir) = existing_log_dir(&claude_tmp) {
34 push_unique(&mut dirs, dir);
35 }
36 }
37
38 let tmp_root = env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
39 if !tmp_root.trim().is_empty() {
40 let path = Path::new(&tmp_root).join("rusty-stack").join("logs");
41 if let Some(dir) = existing_log_dir(&path) {
42 push_unique(&mut dirs, dir);
43 }
44 }
45
46 dirs
47}
48
49pub fn collect_matching_logs(log_dirs: &[PathBuf], patterns: &[&str]) -> Vec<PathBuf> {
51 let mut matches = Vec::new();
52 for dir in log_dirs {
53 if let Ok(entries) = fs::read_dir(dir) {
54 for entry in entries.filter_map(|entry| entry.ok()) {
55 let name = entry.file_name().to_string_lossy().into_owned();
56 if patterns.iter().any(|pat| name.contains(pat)) {
57 let path = entry.path();
58 if path.is_file() {
59 matches.push(path);
60 }
61 }
62 }
63 }
64 }
65
66 matches.sort_by_key(file_modified);
67 matches
68}
69
70pub fn find_latest_log_in_dirs(log_dirs: &[PathBuf], pattern: &str) -> Option<PathBuf> {
73 let matches = collect_matching_logs(log_dirs, &[pattern]);
74 matches
75 .iter()
76 .filter(|path| is_json_log(path))
77 .max_by_key(|path| file_modified(path))
78 .cloned()
79 .or_else(|| matches.last().cloned())
80}
81
82pub fn is_json_log(path: &Path) -> bool {
83 path.extension()
84 .and_then(|ext| ext.to_str())
85 .map(|ext| ext.eq_ignore_ascii_case("json"))
86 .unwrap_or(false)
87}
88
89pub fn extract_benchmark_json_value(contents: &str) -> Option<serde_json::Value> {
92 let trimmed = contents.trim();
93 if trimmed.is_empty() {
94 return None;
95 }
96
97 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
98 return Some(value);
99 }
100
101 let marker_positions: Vec<usize> = contents
103 .match_indices(BENCHMARK_RESULTS_MARKER)
104 .map(|(idx, _)| idx)
105 .filter(|idx| marker_is_line_start(contents, *idx))
106 .collect();
107
108 for marker_idx in marker_positions.into_iter().rev() {
109 let search = &contents[marker_idx + BENCHMARK_RESULTS_MARKER.len()..];
110 if let Some(value) = parse_best_json_object(search) {
111 return Some(value);
112 }
113 }
114
115 parse_best_json_object(contents)
116}
117
118pub fn extract_benchmark_json_string(contents: &str) -> Option<String> {
120 extract_benchmark_json_value(contents).and_then(|value| serde_json::to_string(&value).ok())
121}
122
123fn marker_is_line_start(contents: &str, marker_idx: usize) -> bool {
124 if marker_idx == 0 {
125 return true;
126 }
127 contents[..marker_idx].ends_with('\n')
128}
129
130fn parse_best_json_object(input: &str) -> Option<serde_json::Value> {
131 let mut best: Option<(usize, usize, serde_json::Value)> = None;
132
133 for (idx, ch) in input.char_indices() {
134 if ch != '{' {
135 continue;
136 }
137 let Some(end) = find_balanced_json_object_end(input, idx) else {
138 continue;
139 };
140
141 let candidate = &input[idx..end];
142 let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate) else {
143 continue;
144 };
145
146 let score = benchmark_json_score(&value);
147 let candidate_len = end.saturating_sub(idx);
148 let is_better = best
149 .as_ref()
150 .map(|(best_score, best_len, _)| {
151 score > *best_score || (score == *best_score && candidate_len > *best_len)
152 })
153 .unwrap_or(true);
154 if is_better {
155 best = Some((score, candidate_len, value));
156 }
157 }
158
159 best.map(|(_, _, value)| value)
160}
161
162fn benchmark_json_score(value: &serde_json::Value) -> usize {
163 let mut score = 0usize;
164 if let Some(obj) = value.as_object() {
165 if obj.get("results").and_then(|v| v.as_object()).is_some() {
166 score += 200;
167 }
168 if obj.get("metrics").and_then(|v| v.as_object()).is_some() {
169 score += 100;
170 }
171 if obj.get("errors").and_then(|v| v.as_array()).is_some() {
172 score += 20;
173 }
174 if obj.get("success").and_then(|v| v.as_bool()).is_some() {
175 score += 15;
176 }
177 if obj.get("name").and_then(|v| v.as_str()).is_some() {
178 score += 10;
179 }
180 score += obj.len();
181 }
182
183 score
184}
185
186fn find_balanced_json_object_end(input: &str, start: usize) -> Option<usize> {
187 let mut depth = 0usize;
188 let mut in_string = false;
189 let mut escaped = false;
190
191 for (offset, ch) in input[start..].char_indices() {
192 if in_string {
193 if escaped {
194 escaped = false;
195 continue;
196 }
197 match ch {
198 '\\' => escaped = true,
199 '"' => in_string = false,
200 _ => {}
201 }
202 continue;
203 }
204
205 match ch {
206 '"' => in_string = true,
207 '{' => depth += 1,
208 '}' => {
209 if depth == 0 {
210 return None;
211 }
212 depth -= 1;
213 if depth == 0 {
214 return Some(start + offset + 1);
215 }
216 }
217 _ => {}
218 }
219 }
220
221 None
222}
223
224fn existing_log_dir(path: &Path) -> Option<PathBuf> {
225 if path.is_dir() {
226 Some(path.to_path_buf())
227 } else {
228 None
229 }
230}
231
232fn user_home_from_passwd(user_name: &str) -> Option<String> {
233 let target = user_name.trim();
234 if target.is_empty() {
235 return None;
236 }
237 let passwd = fs::read_to_string("/etc/passwd").ok()?;
238 passwd.lines().find_map(|line| {
239 if line.trim().is_empty() || line.starts_with('#') {
240 return None;
241 }
242 let fields: Vec<&str> = line.split(':').collect();
243 if fields.len() < 6 || fields[0] != target {
244 return None;
245 }
246 let home = fields[5].trim();
247 if home.is_empty() {
248 None
249 } else {
250 Some(home.to_string())
251 }
252 })
253}
254
255fn candidate_log_homes() -> Vec<String> {
256 let mut homes = Vec::new();
257
258 if let Ok(home) = env::var("MLSTACK_USER_HOME") {
259 let trimmed = home.trim();
260 if !trimmed.is_empty() {
261 homes.push(trimmed.to_string());
262 }
263 }
264 if let Ok(home) = env::var("HOME") {
265 let trimmed = home.trim();
266 if !trimmed.is_empty() {
267 homes.push(trimmed.to_string());
268 }
269 }
270 for key in ["SUDO_USER", "USER", "LOGNAME"] {
271 if let Ok(user_name) = env::var(key) {
272 if let Some(home) = user_home_from_passwd(&user_name) {
273 homes.push(home);
274 }
275 }
276 }
277
278 if let Ok(passwd) = fs::read_to_string("/etc/passwd") {
279 for line in passwd.lines() {
280 if line.trim().is_empty() || line.starts_with('#') {
281 continue;
282 }
283 let fields: Vec<&str> = line.split(':').collect();
284 if fields.len() < 6 {
285 continue;
286 }
287 let home = fields[5].trim();
288 if home.is_empty() {
289 continue;
290 }
291 let home_path = Path::new(home);
292 if home_path.join(".mlstack_env").is_file() || home_path.join(".mlstack").is_dir() {
293 homes.push(home.to_string());
294 }
295 }
296 }
297
298 homes.sort();
299 homes.dedup();
300 homes
301}
302
303fn push_unique(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
304 if !dirs.iter().any(|existing| existing == &candidate) {
305 dirs.push(candidate);
306 }
307}
308
309fn file_modified(path: &PathBuf) -> Option<std::time::SystemTime> {
310 fs::metadata(path)
311 .ok()
312 .and_then(|meta| meta.modified().ok())
313}