sivtr-core 0.2.4

Core library for sivtr terminal output and AI coding session processing across local agent providers
Documentation
use unicode_width::UnicodeWidthChar;

use super::entry::{normalize_newlines, render_input, sanitize_command};

pub fn extract_output_from_snapshot(
    prompt: &str,
    command: &str,
    snapshot_lines: &[&str],
    width: usize,
) -> String {
    if snapshot_lines.is_empty() {
        return String::new();
    }

    let prompt_plain = normalize_newlines(&strip_ansi_escapes::strip_str(prompt));
    let command_plain = sanitize_command(command);
    let expected_input = render_input(&prompt_plain, &command_plain);
    if expected_input.is_empty() {
        return String::new();
    }

    let expected_lines = normalized_visual_lines(&expected_input, width.max(1));
    if expected_lines.is_empty() {
        return String::new();
    }
    let prompt_lines = normalized_visual_lines(&prompt_plain, width.max(1));

    let actual_plain_lines: Vec<String> = snapshot_lines
        .iter()
        .map(|line| normalize_visual_line(&strip_ansi_escapes::strip_str(line)))
        .collect();

    if let Some(end) = find_last_subsequence_end(&actual_plain_lines, &expected_lines) {
        return trim_trailing_prompt(
            snapshot_lines[end..].join("\n").trim_end_matches('\n'),
            &prompt_lines,
            width,
        );
    }

    if let Some(last_line) = expected_lines.last() {
        let fallback = [last_line.clone()];
        if let Some(end) = find_last_subsequence_end(&actual_plain_lines, &fallback) {
            return trim_trailing_prompt(
                snapshot_lines[end..].join("\n").trim_end_matches('\n'),
                &prompt_lines,
                width,
            );
        }
    }

    String::new()
}

fn find_last_subsequence_end(haystack: &[String], needle: &[String]) -> Option<usize> {
    if needle.is_empty() || haystack.len() < needle.len() {
        return None;
    }

    (0..=haystack.len() - needle.len())
        .rev()
        .find(|&start| haystack[start..start + needle.len()] == *needle)
        .map(|start| start + needle.len())
}

fn normalized_visual_lines(text: &str, width: usize) -> Vec<String> {
    wrap_visual_lines(text, width)
        .into_iter()
        .map(|line| normalize_visual_line(&line))
        .collect()
}

fn trim_trailing_prompt(output: &str, prompt_lines: &[String], width: usize) -> String {
    if output.is_empty() || prompt_lines.is_empty() {
        return output.to_string();
    }

    let mut raw_lines: Vec<&str> = output.lines().collect();
    if raw_lines.is_empty() {
        return String::new();
    }

    let normalized_output = normalized_visual_lines(&strip_ansi_escapes::strip_str(output), width);

    if normalized_output.ends_with(prompt_lines) {
        let keep = raw_lines.len().saturating_sub(prompt_lines.len());
        raw_lines.truncate(keep);
        return raw_lines.join("\n").trim_end_matches('\n').to_string();
    }

    let last_prompt_line = prompt_lines
        .iter()
        .rev()
        .find(|line| !line.is_empty())
        .cloned()
        .unwrap_or_default();
    if last_prompt_line.is_empty() {
        return output.to_string();
    }

    let last_output_line = normalize_visual_line(&strip_ansi_escapes::strip_str(
        raw_lines.last().copied().unwrap_or_default(),
    ));

    if last_output_line == last_prompt_line {
        let keep = raw_lines.len().saturating_sub(prompt_lines.len());
        raw_lines.truncate(keep);
        return raw_lines.join("\n").trim_end_matches('\n').to_string();
    }

    output.to_string()
}

fn normalize_visual_line(line: &str) -> String {
    line.trim_end().to_string()
}

fn wrap_visual_lines(text: &str, width: usize) -> Vec<String> {
    let width = width.max(1);
    let mut wrapped = Vec::new();

    for logical_line in text.lines() {
        if logical_line.is_empty() {
            wrapped.push(String::new());
            continue;
        }

        let mut current = String::new();
        let mut current_width = 0;

        for ch in logical_line.chars() {
            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1);
            if current_width + ch_width > width && !current.is_empty() {
                wrapped.push(current);
                current = String::new();
                current_width = 0;
            }
            current.push(ch);
            current_width += ch_width;
        }

        wrapped.push(current);
    }

    if text.is_empty() {
        wrapped.push(String::new());
    }

    wrapped
}

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

    #[test]
    fn extracts_output_after_latest_input_block() {
        let lines = vec![
            "repo on main",
            "❯  cargo test",
            "old",
            "repo on main",
            "❯  cargo test",
            "ok",
        ];
        assert_eq!(
            extract_output_from_snapshot("repo on main\n", "cargo test", &lines, 120),
            "ok"
        );
    }

    #[test]
    fn extracts_output_when_prompt_scrolled_but_command_line_remains() {
        let lines = vec!["❯  cargo test", "line1", "line2"];
        assert_eq!(
            extract_output_from_snapshot("repo on main\n", "cargo test", &lines, 120),
            "line1\nline2"
        );
    }

    #[test]
    fn strips_trailing_prompt_from_captured_output() {
        let lines = vec!["repo on main", "❯  cargo test", "ok", "repo on main", ""];
        assert_eq!(
            extract_output_from_snapshot("repo on main\n", "cargo test", &lines, 120),
            "ok"
        );
    }

    #[test]
    fn strips_trailing_prompt_even_when_glyphs_degrade_in_snapshot() {
        let lines = vec![
            "sift on � main !14 ?2 ⇡1",
            "❯  cargo test",
            "ok",
            "sift on � main !14 ?2 ⇡1",
            "",
        ];
        assert_eq!(
            extract_output_from_snapshot(
                "sift on 󰊢 main !14 ?2 ⇡1\n",
                "cargo test",
                &lines,
                120
            ),
            "ok"
        );
    }
}