const MAX_CHARS: usize = 150;
pub fn condense_for_speech(text: &str, severity: &str, project: &str) -> String {
let no_fences = strip_code_fences(text);
let no_inline = strip_inline_code(&no_fences);
let plain: String = no_inline
.chars()
.filter(|&c| !matches!(c, '#' | '*' | '_' | '~' | '>' | '|'))
.collect();
let collapsed = plain.split_whitespace().collect::<Vec<_>>().join(" ");
let sentence = first_sentence(&collapsed, MAX_CHARS);
let prefix = match severity {
"block" => "차단 경고",
"warn" => "경고",
"suggest" => "제안",
_ => "알림",
};
format!("{}. {}. {}", prefix, project, sentence)
}
fn strip_code_fences(text: &str) -> String {
let mut out = String::new();
let mut in_fence = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if !in_fence {
out.push_str(line);
out.push('\n');
}
}
out
}
fn strip_inline_code(text: &str) -> String {
let mut out = String::new();
let mut in_code = false;
for c in text.chars() {
if c == '`' {
in_code = !in_code;
} else if !in_code {
out.push(c);
}
}
out
}
fn first_sentence(text: &str, max_chars: usize) -> String {
let sentence = text
.split(['.', '!', '?'])
.map(str::trim)
.find(|s| !s.is_empty())
.unwrap_or(text.trim())
.to_string();
if sentence.chars().count() <= max_chars {
sentence
} else {
let truncated: String = sentence.chars().take(max_chars - 2).collect();
format!("{}…", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_markdown() {
let text = "**경고** *useEffect* 의 `cleanup` 함수 누락. 메모리 leak 가능.";
let out = condense_for_speech(text, "warn", "Devist");
assert!(out.starts_with("경고. Devist."));
assert!(!out.contains('*'));
assert!(!out.contains('`'));
}
#[test]
fn strips_code_fences() {
let text = "useEffect 정리:\n```rust\nlet x = 1;\n```\n끝.";
let out = condense_for_speech(text, "info", "p");
assert!(!out.contains("let x"));
}
#[test]
fn caps_long_text() {
let long = "ㄱ".repeat(200);
let out = condense_for_speech(&long, "info", "p");
assert!(out.chars().count() <= 200);
}
}