blob: b8a9c632ee7f5e10af65533de015e2ca82b4cae8 [file]
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
/// Collapse double-spaced text from Doris 3.0 web export.
/// "Profile ID" → "Profile ID", but preserve indentation.
pub fn normalize_text(input: &str) -> String {
let mut result = String::with_capacity(input.len());
for line in input.lines() {
// Preserve leading whitespace
let trimmed = line.trim_end();
let leading = &trimmed[..trimmed.len() - trimmed.trim_start().len()];
let content = trimmed.trim_start();
// Collapse runs of 2+ spaces within the content (not leading indent)
static MULTI_SPACE: Lazy<Regex> = Lazy::new(|| Regex::new(r" +").unwrap());
let normalized = MULTI_SPACE.replace_all(content, " ");
result.push_str(leading);
result.push_str(&normalized);
result.push('\n');
}
// Also remove \r
result.replace('\r', "")
}
/// Split a normalized profile text into named sections.
pub fn split_sections(text: &str) -> HashMap<String, String> {
static SECTION_RE: Lazy<Regex> = Lazy::new(|| {
// Top-level profile section headers. Doris spells these differently across
// versions: with or without internal spaces ("Changed Session Variables" vs
// "ChangedSessionVariables", "Physical Plan" vs "PhysicalPlan"), and some carry a
// parenthesized suffix ("DetailProfile(<query_id>):"). Match every spelling and
// normalize to a canonical key (see canonical_section). DetailProfile and Appendix
// are matched purely as boundaries so their bodies can't bleed into the preceding
// MergedProfile section — which would fabricate empty fragments.
Regex::new(
r"(?m)^\s*(Summary|Execution ?Summary|Changed ?Session ?Variables|Physical ?Plan|MergedProfile|DetailProfile|Appendix)\b.*:\s*$",
)
.unwrap()
});
let mut sections = HashMap::new();
let mut current_name: Option<String> = None;
let mut current_content = String::new();
for line in text.lines() {
if let Some(caps) = SECTION_RE.captures(line) {
// Save previous section
if let Some(name) = current_name.take() {
sections.insert(name, current_content.trim().to_string());
current_content.clear();
}
current_name = Some(canonical_section(&caps[1]));
} else if current_name.is_some() {
current_content.push_str(line);
current_content.push('\n');
}
}
// Save last section
if let Some(name) = current_name {
sections.insert(name, current_content.trim().to_string());
}
sections
}
/// Normalize a section header to a canonical lookup key, so spelling variants
/// across Doris versions (with/without internal spaces) resolve to the one key
/// the parsers query for.
fn canonical_section(raw_name: &str) -> String {
let compact: String = raw_name.split_whitespace().collect();
match compact.as_str() {
"ExecutionSummary" => "Execution Summary".to_string(),
"ChangedSessionVariables" => "Changed Session Variables".to_string(),
"PhysicalPlan" => "Physical Plan".to_string(),
_ => compact,
}
}
/// Parse key-value pairs from Summary or Execution Summary sections.
/// Lines look like: " - Key: Value"
pub fn parse_kv_section(text: &str) -> HashMap<String, String> {
static KV_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*-\s+(.+?):\s+(.+)$").unwrap());
let mut map = HashMap::new();
for line in text.lines() {
if let Some(caps) = KV_RE.captures(line) {
let key = caps[1].trim().to_string();
let value = caps[2].trim().to_string();
map.insert(key, value);
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_double_spaces() {
let result = normalize_text(" - Profile ID: abc123");
// Leading indent is preserved, double spaces in content collapsed
assert!(result.contains("- Profile ID: abc123"));
}
#[test]
fn test_parse_kv() {
let text = " - Total: 32sec281ms\n - Task State: OK\n";
let kv = parse_kv_section(text);
assert_eq!(kv.get("Total"), Some(&"32sec281ms".to_string()));
assert_eq!(kv.get("Task State"), Some(&"OK".to_string()));
}
#[test]
fn test_split_sections_spellings_and_boundaries() {
// No-space spellings + a parenthesized DetailProfile header that must act as a
// boundary so its "Fragment 0:" cannot leak into MergedProfile.
let text = "\
MergedProfile:
Fragments:
Fragment 0:
Pipeline 0(instance_num=1):
DetailProfile(abc-123):
Fragments:
Fragment 0:
Pipeline 0(host=h):
ChangedSessionVariables:
[ { \"VarName\": \"enable_profile\" } ]
PhysicalPlan:
PhysicalResultSink[1]
";
let sections = split_sections(text);
// Canonical keys exist for the no-space spellings.
assert!(sections.contains_key("Changed Session Variables"));
assert!(sections.contains_key("Physical Plan"));
// DetailProfile is its own section, so MergedProfile stops before it and does
// not contain the DetailProfile body.
let merged = sections.get("MergedProfile").expect("MergedProfile present");
assert!(merged.contains("Pipeline 0(instance_num=1)"));
assert!(
!merged.contains("DetailProfile") && !merged.contains("host=h"),
"DetailProfile body must not bleed into MergedProfile, got: {merged}"
);
// Physical Plan body is captured, not swallowed by the previous section.
assert!(sections
.get("Physical Plan")
.map(|p| p.contains("PhysicalResultSink"))
.unwrap_or(false));
}
}