agents-docs-manager 0.1.1

Manage AGENTS.md and Docs markdown indexes for agent-oriented repositories.
use std::io::{self, IsTerminal, Read};

use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use serde_json::json;

use crate::utils::error::{AppError, AppResult};

pub fn apply_stdin_patch(original: &str, expected_path: &str) -> AppResult<String> {
    let patch_content = read_stdin_patch()?;
    let patches = parse_patch_set(&patch_content)?;

    let [file_patch] = patches.as_slice() else {
        return Err(AppError::input_with_details(
            "patch_must_target_single_document",
            "patch must contain exactly one file patch",
            json!({ "patches": patches.len() }),
        ));
    };

    if !matches!(file_patch.operation(), FileOperation::Modify { .. }) {
        return Err(AppError::input_with_details(
            "unsupported_patch_operation",
            "patch must modify an existing document",
            json!({ "operation": format!("{:?}", file_patch.operation()) }),
        ));
    }

    ensure_patch_targets_document(file_patch, expected_path)?;

    let text_patch = file_patch.patch().as_text().ok_or_else(|| {
        AppError::input_with_details(
            "unsupported_patch_kind",
            "patch must be a text unified diff",
            json!({ "path": expected_path }),
        )
    })?;

    diffy::apply(original, text_patch).map_err(|error| {
        AppError::validation(
            "patch_apply_failed",
            "patch does not apply to current document",
            json!({
                "path": expected_path,
                "source": error.to_string()
            }),
        )
    })
}

fn read_stdin_patch() -> AppResult<String> {
    let stdin = io::stdin();
    if stdin.is_terminal() {
        return Err(AppError::input(
            "patch_stdin_required",
            "patch content must be provided on stdin",
        ));
    }

    let mut input = String::new();
    stdin.lock().read_to_string(&mut input).map_err(|error| {
        AppError::fs(
            "read_stdin_failed",
            "failed to read patch content from stdin",
            "stdin",
            &error,
        )
    })?;

    if input.trim().is_empty() {
        return Err(AppError::input(
            "empty_patch",
            "patch content must not be empty",
        ));
    }

    Ok(input)
}

fn parse_patch_set(input: &str) -> AppResult<Vec<FilePatch<'_, str>>> {
    let git_error = match PatchSet::parse(input, ParseOptions::gitdiff()).collect::<Result<_, _>>()
    {
        Ok(patches) => return Ok(patches),
        Err(error) => error,
    };

    PatchSet::parse(input, ParseOptions::unidiff())
        .collect::<Result<_, _>>()
        .map_err(|unidiff_error| {
            AppError::input_with_details(
                "invalid_patch",
                "patch is not a valid unified diff",
                json!({
                    "gitdiff_source": git_error.to_string(),
                    "unidiff_source": unidiff_error.to_string()
                }),
            )
        })
}

fn ensure_patch_targets_document(
    file_patch: &FilePatch<'_, str>,
    expected_path: &str,
) -> AppResult<()> {
    let FileOperation::Modify { original, modified } = file_patch.operation() else {
        return Ok(());
    };
    let original = normalize_patch_path(original.as_ref());
    let modified = normalize_patch_path(modified.as_ref());
    if original == expected_path && modified == expected_path {
        return Ok(());
    }

    Err(AppError::input_with_details(
        "patch_target_mismatch",
        "patch must target the selected document",
        json!({
            "expected": expected_path,
            "actual_original": original,
            "actual_modified": modified
        }),
    ))
}

fn normalize_patch_path(path: &str) -> &str {
    path.strip_prefix("a/")
        .or_else(|| path.strip_prefix("b/"))
        .unwrap_or(path)
}