a3s 0.9.8

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use crate::tui::deep_research_report_generation::{
    ReportSectionComposition, ReportSectionRhythm, ReportSectionTreatment,
};

#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct ReportComposition {
    pub(super) body: String,
    pub(super) toc: String,
    pub(super) hero_guide: String,
    pub(super) finding_count: usize,
}

pub(super) fn compose_report_fragment(
    fragment: &str,
    language: &str,
    section_plan: &[ReportSectionTreatment],
) -> ReportComposition {
    let Some(first_heading) = fragment.find("<h2>") else {
        return ReportComposition {
            body: format!(
                "<section class=\"report-section section--narrative\"><div class=\"section-body prose\">{fragment}</div></section>"
            ),
            ..ReportComposition::default()
        };
    };

    let mut body = String::new();
    let preamble = fragment[..first_heading].trim();
    if !preamble.is_empty() {
        body.push_str(&format!(
            "<section class=\"report-section section--lead\"><div class=\"section-body prose\">{preamble}</div></section>"
        ));
    }

    let mut toc = String::from("<nav class=\"toc\" aria-label=\"");
    toc.push_str(if language == "zh-CN" {
        "报告目录"
    } else {
        "Report contents"
    });
    toc.push_str("\">");

    let mut remaining = &fragment[first_heading..];
    let mut section_count = 0usize;
    let mut finding_count = 0usize;
    let mut hero_links = String::new();
    while let Some(heading_start) = remaining.find("<h2>") {
        let after_heading = &remaining[heading_start + "<h2>".len()..];
        let Some(heading_end) = after_heading.find("</h2>") else {
            body.push_str(remaining);
            break;
        };
        let heading_html = &after_heading[..heading_end];
        let content_start = heading_start + "<h2>".len() + heading_end + "</h2>".len();
        let after_current = &remaining[content_start..];
        let next_heading = after_current.find("<h2>").unwrap_or(after_current.len());
        let raw_content = after_current[..next_heading].trim();

        section_count += 1;
        let section_id = format!("section-{section_count}");
        let heading_text = decode_basic_entities(&strip_html_tags(heading_html));
        let kind = section_kind(&heading_text);
        let treatment = matching_section_treatment(&heading_text, section_plan);
        let composition = treatment
            .map(|treatment| treatment.composition)
            .unwrap_or_else(|| default_section_composition(kind));
        let rhythm = treatment
            .map(|treatment| treatment.rhythm)
            .unwrap_or_else(|| default_section_rhythm(kind, composition));
        let (content, section_findings) =
            compose_section_content(raw_content, &heading_text, composition);
        finding_count = finding_count.saturating_add(section_findings);

        toc.push_str(&format!(
            "<a href=\"#{section_id}\"><span>{section_count:02}</span>{}</a>",
            escape_attribute(&heading_text)
        ));
        if section_count <= 4 {
            hero_links.push_str(&format!(
                "<li><a href=\"#{section_id}\"><span>{section_count:02}</span><b>{}</b></a></li>",
                escape_attribute(&heading_text)
            ));
        }
        body.push_str(&format!(
            "<section id=\"{section_id}\" class=\"report-section section--{kind} {} {}\"><div class=\"section-index\">{section_count:02}</div><h2>{heading_html}</h2><div class=\"section-body\">{content}</div></section>",
            rhythm.class_name(),
            composition.class_name(),
        ));

        remaining = &after_current[next_heading..];
        if remaining.is_empty() {
            break;
        }
    }
    toc.push_str("</nav>");
    let hero_guide = if hero_links.is_empty() {
        String::new()
    } else {
        let label = if language == "zh-CN" {
            "阅读路径"
        } else {
            "Reading path"
        };
        format!(
            "<aside class=\"hero-map\" aria-label=\"{label}\"><p class=\"profile-label\">{label}</p><ol>{hero_links}</ol></aside>"
        )
    };

    ReportComposition {
        body,
        toc,
        hero_guide,
        finding_count,
    }
}

fn compose_section_content(
    content: &str,
    heading: &str,
    composition: ReportSectionComposition,
) -> (String, usize) {
    match composition {
        ReportSectionComposition::KeyPoints => compose_subheaded_blocks(
            content,
            heading,
            "key-points-list",
            "key-point",
            "key-point-number",
        ),
        ReportSectionComposition::Timeline => {
            let (content, _) = compose_subheaded_blocks(
                content,
                heading,
                "timeline-list",
                "timeline-entry",
                "timeline-marker",
            );
            (content, 0)
        }
        ReportSectionComposition::Process => {
            let (content, _) = compose_subheaded_blocks(
                content,
                heading,
                "process-list",
                "process-step",
                "process-number",
            );
            (content, 0)
        }
        _ => (wrap_tables(content, heading), 0),
    }
}

fn compose_subheaded_blocks(
    content: &str,
    label: &str,
    list_class: &str,
    item_class: &str,
    number_class: &str,
) -> (String, usize) {
    let Some(first_heading) = content.find("<h3>") else {
        return (wrap_tables(content, label), 0);
    };
    let lead = content[..first_heading].trim();
    let mut output = String::new();
    if !lead.is_empty() {
        output.push_str(&format!(
            "<div class=\"composition-lead prose\">{lead}</div>"
        ));
    }
    output.push_str(&format!("<div class=\"{list_class}\">"));

    let mut remaining = &content[first_heading..];
    let mut count = 0usize;
    while let Some(heading_start) = remaining.find("<h3>") {
        let after_heading = &remaining[heading_start + "<h3>".len()..];
        let Some(heading_end) = after_heading.find("</h3>") else {
            output.push_str(remaining);
            break;
        };
        let heading = &after_heading[..heading_end];
        let content_start = heading_start + "<h3>".len() + heading_end + "</h3>".len();
        let after_current = &remaining[content_start..];
        let next_heading = after_current.find("<h3>").unwrap_or(after_current.len());
        let detail = wrap_tables(
            after_current[..next_heading].trim(),
            &strip_html_tags(heading),
        );
        count += 1;
        output.push_str(&format!(
            "<article class=\"{item_class}\"><div class=\"{number_class}\">{count:02}</div><div class=\"composition-content\"><h3>{heading}</h3>{detail}</div></article>"
        ));
        remaining = &after_current[next_heading..];
        if remaining.is_empty() {
            break;
        }
    }
    output.push_str("</div>");
    (output, count)
}

fn matching_section_treatment<'a>(
    heading: &str,
    section_plan: &'a [ReportSectionTreatment],
) -> Option<&'a ReportSectionTreatment> {
    let heading = normalize_heading(heading);
    section_plan
        .iter()
        .find(|treatment| normalize_heading(&treatment.heading) == heading)
}

fn normalize_heading(value: &str) -> String {
    value
        .chars()
        .filter(|ch| ch.is_alphanumeric())
        .flat_map(char::to_lowercase)
        .collect()
}

fn default_section_composition(kind: &str) -> ReportSectionComposition {
    match kind {
        "findings" => ReportSectionComposition::KeyPoints,
        "matrix" => ReportSectionComposition::Comparison,
        "sources" => ReportSectionComposition::SourceLedger,
        _ => ReportSectionComposition::Prose,
    }
}

fn default_section_rhythm(
    kind: &str,
    composition: ReportSectionComposition,
) -> ReportSectionRhythm {
    match (kind, composition) {
        ("summary" | "confidence", _) => ReportSectionRhythm::Anchor,
        ("caveats", _) => ReportSectionRhythm::Breathing,
        (_, ReportSectionComposition::Comparison)
        | (_, ReportSectionComposition::KeyPoints)
        | (_, ReportSectionComposition::Evidence)
        | (_, ReportSectionComposition::SourceLedger) => ReportSectionRhythm::Dense,
        _ => ReportSectionRhythm::Breathing,
    }
}

fn wrap_tables(content: &str, label: &str) -> String {
    if !content.contains("<table>") {
        return content.to_string();
    }
    content
        .replace(
            "<table>",
            &format!(
                "<div class=\"table-wrap\" role=\"region\" aria-label=\"{}\" tabindex=\"0\"><table>",
                escape_attribute(label)
            ),
        )
        .replace("</table>", "</table></div>")
}

fn section_kind(heading: &str) -> &'static str {
    let lower = heading.to_ascii_lowercase();
    if lower.contains("summary") || heading.contains("摘要") {
        "summary"
    } else if lower.contains("finding") || heading.contains("发现") {
        "findings"
    } else if lower.contains("matrix") || heading.contains("矩阵") {
        "matrix"
    } else if lower.contains("caveat")
        || lower.contains("gap")
        || lower.contains("limit")
        || heading.contains("局限")
        || heading.contains("注意")
    {
        "caveats"
    } else if lower.contains("confidence")
        || lower.contains("quality")
        || heading.contains("置信")
        || heading.contains("质量")
    {
        "confidence"
    } else if lower.contains("source")
        || lower.contains("reference")
        || heading.contains("来源")
        || heading.contains("参考")
    {
        "sources"
    } else {
        "narrative"
    }
}

fn strip_html_tags(value: &str) -> String {
    let mut output = String::with_capacity(value.len());
    let mut in_tag = false;
    for ch in value.chars() {
        match ch {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => output.push(ch),
            _ => {}
        }
    }
    output
}

fn escape_attribute(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('"', "&quot;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn decode_basic_entities(value: &str) -> String {
    value
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn report_sections_gain_distinct_compositions_and_navigation() {
        let fragment = "<h2>Executive Summary</h2><ul><li>One</li><li>Two</li></ul><h2>Key Findings</h2><h3>First</h3><p>Detail.</p><h3>Second</h3><p>Detail.</p><h2>Evidence Matrix</h2><table><tr><td>A</td></tr></table><h2>Sources</h2><ul><li>Source</li></ul>";
        let composition = compose_report_fragment(fragment, "en", &[]);

        assert_eq!(composition.finding_count, 2);
        assert!(composition.body.contains("section--summary"));
        assert!(composition.body.contains("class=\"key-point\""));
        assert!(composition.body.contains("class=\"table-wrap\""));
        assert!(composition.body.contains("section--sources"));
        assert!(composition.toc.contains("href=\"#section-4\""));
        assert!(composition.hero_guide.contains("Reading path"));
    }

    #[test]
    fn section_plan_controls_rhythm_and_semantic_composition_without_heading_keywords() {
        let fragment = "<h2>What changed</h2><h3>Before</h3><p>Old state.</p><h3>After</h3><p>New state.</p><h2>What to do next</h2><ol><li>Act.</li></ol>";
        let plan = [
            ReportSectionTreatment {
                heading: "What changed".to_string(),
                rhythm: ReportSectionRhythm::Anchor,
                composition: ReportSectionComposition::Timeline,
            },
            ReportSectionTreatment {
                heading: "What to do next".to_string(),
                rhythm: ReportSectionRhythm::Dense,
                composition: ReportSectionComposition::Process,
            },
        ];

        let composition = compose_report_fragment(fragment, "en", &plan);

        assert!(composition
            .body
            .contains("rhythm-anchor composition-timeline"));
        assert!(composition.body.contains("class=\"timeline-entry\""));
        assert!(composition
            .body
            .contains("rhythm-dense composition-process"));
    }
}