llm 1.3.8

A Rust library unifying multiple LLM backends.
Documentation
use thiserror::Error;

use super::{DiffFile, DiffHunk, DiffLine, DiffView, HunkDecision, LineKind};

#[derive(Debug, Error)]
pub enum DiffParseError {
    #[error("diff is empty")]
    EmptyDiff,
    #[error("missing file header before hunk")]
    MissingFileHeader,
    #[error("invalid hunk header: {0}")]
    InvalidHunkHeader(String),
}

pub fn parse_unified_diff(input: &str) -> Result<DiffView, DiffParseError> {
    let mut view = DiffView::default();
    let mut current_file: Option<usize> = None;
    let mut pending_old: Option<String> = None;

    for line in input.lines() {
        if let Some(old) = line.strip_prefix("--- ") {
            pending_old = Some(clean_path(old.trim()));
            continue;
        }
        if let Some(new) = line.strip_prefix("+++ ") {
            let old = pending_old
                .take()
                .ok_or(DiffParseError::MissingFileHeader)?;
            let new = clean_path(new.trim());
            view.files.push(DiffFile {
                old_path: old,
                new_path: new,
                hunks: Vec::new(),
            });
            current_file = Some(view.files.len().saturating_sub(1));
            continue;
        }
        if line.starts_with("@@") {
            let (old_start, new_start, header) = parse_hunk_header(line)?;
            let idx = current_file.ok_or(DiffParseError::MissingFileHeader)?;
            view.files[idx].hunks.push(DiffHunk {
                header,
                old_start,
                new_start,
                lines: Vec::new(),
                decision: HunkDecision::Pending,
            });
            continue;
        }
        let Some(idx) = current_file else {
            continue;
        };
        if view.files[idx].hunks.is_empty() {
            continue;
        }
        if let Some((kind, content)) = parse_diff_line(line) {
            let hunk_idx = view.files[idx].hunks.len().saturating_sub(1);
            view.files[idx].hunks[hunk_idx]
                .lines
                .push(DiffLine { kind, content });
        }
    }

    if view.files.is_empty() {
        return Err(DiffParseError::EmptyDiff);
    }
    Ok(view)
}

fn parse_hunk_header(line: &str) -> Result<(usize, usize, String), DiffParseError> {
    let trimmed = line.trim();
    let start = trimmed
        .find("@@")
        .ok_or_else(|| DiffParseError::InvalidHunkHeader(line.to_string()))?;
    let rest = &trimmed[start + 2..];
    let end = rest
        .find("@@")
        .ok_or_else(|| DiffParseError::InvalidHunkHeader(line.to_string()))?;
    let body = rest[..end].trim();
    let parts: Vec<&str> = body.split_whitespace().collect();
    if parts.len() < 2 {
        return Err(DiffParseError::InvalidHunkHeader(line.to_string()));
    }
    let old_start = parse_range(parts[0], '-')?;
    let new_start = parse_range(parts[1], '+')?;
    Ok((old_start, new_start, line.to_string()))
}

fn parse_range(token: &str, prefix: char) -> Result<usize, DiffParseError> {
    let value = token
        .strip_prefix(prefix)
        .ok_or_else(|| DiffParseError::InvalidHunkHeader(token.to_string()))?;
    let number = value.split(',').next().unwrap_or(value);
    number
        .parse::<usize>()
        .map_err(|_| DiffParseError::InvalidHunkHeader(token.to_string()))
}

fn parse_diff_line(line: &str) -> Option<(LineKind, String)> {
    let mut chars = line.chars();
    let prefix = chars.next()?;
    let kind = match prefix {
        '+' => LineKind::Add,
        '-' => LineKind::Remove,
        ' ' => LineKind::Context,
        _ => return None,
    };
    Some((kind, chars.collect()))
}

fn clean_path(raw: &str) -> String {
    raw.trim_start_matches("a/")
        .trim_start_matches("b/")
        .to_string()
}

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

    #[test]
    fn parses_simple_diff() {
        let raw = "--- a/foo.txt\n+++ b/foo.txt\n@@ -1,2 +1,2 @@\n-old\n+new\n";
        let diff = parse_unified_diff(raw).unwrap();
        assert_eq!(diff.files.len(), 1);
        assert_eq!(diff.files[0].hunks.len(), 1);
    }
}