asurada 0.3.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
// 어드바이스 텍스트 → 음성용 단축 문장.
//
// 마크다운/코드 펜스/inline code 등 음성으로 읽기 어려운 것들을 제거하고
// 첫 문장만 추려서 severity prefix 와 함께 반환.

const MAX_CHARS: usize = 150;

/// `severity`, `project`, `text` 를 받아 음성으로 읽기 좋은 한 문장으로.
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);
    }
}