devcat 0.1.5

A micro-version control system for your AI development loop.
use crate::error::Result;
use clap::Args;
use regex::Regex;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};

#[derive(Args, Debug)]
#[command(name = "patch", about = "Applies search/replace code patches from piped input or a file.")]
pub struct PatchArgs {
    #[arg(help = "Optional path to the input file. If not provided, reads from stdin.")]
    pub input: Option<PathBuf>,

    #[arg(long, help = "Dry run: show what would be patched without modifying files.")]
    pub dry_run: bool,
}

pub fn run(args: PatchArgs) -> Result<()> {
    let content = match &args.input {
        Some(path) => fs::read_to_string(path)?,
        None => {
            let mut buffer = String::new();
            io::stdin().read_to_string(&mut buffer)?;
            buffer
        }
    };

    execute_patch(&content, Path::new("."), args.dry_run)
}

pub fn execute_patch(content: &str, outdir_arg: &Path, dry_run: bool) -> Result<()> {
    let outdir = outdir_arg.canonicalize().unwrap_or_else(|_| outdir_arg.to_path_buf());
    let normalized_content = content.replace("\r\n", "\n");
    let file_block_re = Regex::new(r"(?m)(?:^|\n)([^\n]+)\n```[a-zA-Z0-9_]*\n([\s\S]*?)\n```")?;
    let patch_block_re = Regex::new(r"(?s)<<<< SEARCH[ \t]*\n(.*?)\n====[ \t]*\n(.*?)\n>>>> REPLACE[ \t]*")?;

    let mut applied_patches = 0;

    for file_cap in file_block_re.captures_iter(&normalized_content) {
        let file_path_str = file_cap[1].trim();
        let raw_path = Path::new(file_path_str);
        let code_block = &file_cap[2];

        if file_path_str.is_empty() || file_path_str.contains("..") || raw_path.is_absolute() {
            continue;
        }

        if !code_block.contains("<<<< SEARCH") || !code_block.contains(">>>> REPLACE") {
            continue;
        }

        let file_path = outdir.join(raw_path);
        if !file_path.exists() {
            log::warn!("warning: file_not_found_for_patching, path={}", file_path.display());
            continue;
        }

        let mut file_content = fs::read_to_string(&file_path)?.replace("\r\n", "\n");
        let mut patched = false;

        for patch_cap in patch_block_re.captures_iter(code_block) {
            let search_str = &patch_cap[1];
            let replace_str = &patch_cap[2];

            if file_content.contains(search_str) {
                file_content = file_content.replace(search_str, replace_str);
                patched = true;
                applied_patches += 1;
            } else {
                log::error!("error: search_block_not_found, path={}", file_path.display());
            }
        }

        if patched {
            if dry_run {
                log::info!("plan_patch, path={}", file_path.display());
            } else {
                fs::write(&file_path, file_content)?;
                log::info!("patched_file, path={}", file_path.display());
            }
        }
    }

    log::info!("summary, patches={}, dry_run={}, outdir={}", applied_patches, dry_run, outdir.display());
    Ok(())
}