llm 1.3.8

A Rust library unifying multiple LLM backends.
Documentation
use std::fs;
use std::path::{Component, Path, PathBuf};

use thiserror::Error;

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

#[derive(Debug, Error)]
pub enum DiffApplyError {
    #[error("no accepted hunks to apply")]
    NothingToApply,
    #[error("unsupported diff path: {0}")]
    InvalidPath(String),
    #[error("context mismatch while applying diff")]
    ContextMismatch,
    #[error("missing file header for diff")]
    MissingFile,
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
}

pub fn apply_diff(diff: &DiffView, base_dir: &Path) -> Result<Vec<PathBuf>, DiffApplyError> {
    let mut applied = Vec::new();
    let mut any = false;
    for file in &diff.files {
        if file
            .hunks
            .iter()
            .any(|h| h.decision == HunkDecision::Accepted)
        {
            any = true;
            if let Some(path) = apply_file(file, base_dir)? {
                applied.push(path);
            }
        }
    }
    if !any {
        return Err(DiffApplyError::NothingToApply);
    }
    Ok(applied)
}

fn apply_file(file: &DiffFile, base_dir: &Path) -> Result<Option<PathBuf>, DiffApplyError> {
    let target_path = resolve_target_path(file)?;
    let full_path = base_dir.join(&target_path);
    let (original_lines, had_newline) = read_lines(&full_path)?;

    let mut output = Vec::new();
    let mut index = 0usize;
    for hunk in file
        .hunks
        .iter()
        .filter(|h| h.decision == HunkDecision::Accepted)
    {
        let start = hunk.old_start.saturating_sub(1);
        if start > original_lines.len() {
            return Err(DiffApplyError::ContextMismatch);
        }
        output.extend_from_slice(&original_lines[index..start]);
        index = start;
        apply_hunk_lines(hunk, &original_lines, &mut index, &mut output)?;
    }
    output.extend_from_slice(&original_lines[index..]);

    write_with_backup(&full_path, &output, had_newline)?;
    Ok(Some(full_path))
}

fn resolve_target_path(file: &DiffFile) -> Result<PathBuf, DiffApplyError> {
    let path = if file.new_path != "/dev/null" {
        &file.new_path
    } else if file.old_path != "/dev/null" {
        &file.old_path
    } else {
        return Err(DiffApplyError::MissingFile);
    };
    sanitize_path(path)
}

fn sanitize_path(raw: &str) -> Result<PathBuf, DiffApplyError> {
    let path = PathBuf::from(raw);
    if path.is_absolute() {
        return Err(DiffApplyError::InvalidPath(raw.to_string()));
    }
    if path.components().any(|c| matches!(c, Component::ParentDir)) {
        return Err(DiffApplyError::InvalidPath(raw.to_string()));
    }
    Ok(path)
}

fn read_lines(path: &Path) -> Result<(Vec<String>, bool), DiffApplyError> {
    match fs::read_to_string(path) {
        Ok(contents) => {
            let had_newline = contents.ends_with('\n');
            Ok((
                contents.lines().map(|line| line.to_string()).collect(),
                had_newline,
            ))
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
        Err(err) => Err(err.into()),
    }
}

fn apply_hunk_lines(
    hunk: &DiffHunk,
    original: &[String],
    index: &mut usize,
    output: &mut Vec<String>,
) -> Result<(), DiffApplyError> {
    for line in &hunk.lines {
        match line.kind {
            LineKind::Context => {
                ensure_line(original, *index, line)?;
                output.push(original[*index].clone());
                *index += 1;
            }
            LineKind::Remove => {
                ensure_line(original, *index, line)?;
                *index += 1;
            }
            LineKind::Add => output.push(line.content.clone()),
        }
    }
    Ok(())
}

fn ensure_line(original: &[String], index: usize, line: &DiffLine) -> Result<(), DiffApplyError> {
    if original.get(index).map(|v| v.as_str()) == Some(line.content.as_str()) {
        Ok(())
    } else {
        Err(DiffApplyError::ContextMismatch)
    }
}

fn write_with_backup(
    path: &Path,
    lines: &[String],
    had_newline: bool,
) -> Result<(), DiffApplyError> {
    if path.exists() {
        let backup = PathBuf::from(format!("{}.bak", path.display()));
        fs::copy(path, backup)?;
    }
    let mut contents = lines.join("\n");
    if had_newline {
        contents.push('\n');
    }
    fs::write(path, contents)?;
    Ok(())
}