ctxpack 0.1.0

Configuration-driven source packing for LLM context, with a versioning protocol for incremental patch/replace
pub mod diff;
pub mod patch_gen;
pub mod replace_gen;

use crate::config::VersioningConfig;

#[derive(Debug)]
pub enum VersionAction {
    Unchanged,
    Patch { pid: u32, content: String },
    Replace { generation: u32, content: String },
}

pub struct VersionContext<'a> {
    pub current_gen: u32,
    pub current_pid: u32,
    pub config: &'a VersioningConfig,
    pub prefix: &'a str,
    pub anchor_interval: u32,
}

pub fn determine_action(
    fid: u32,
    old_encoded: &str,
    new_encoded: &str,
    context: VersionContext<'_>,
) -> VersionAction {
    let old_patch_view = patch_view(old_encoded, context.anchor_interval);
    let new_patch_view = patch_view(new_encoded, context.anchor_interval);

    if old_patch_view == new_patch_view {
        return VersionAction::Unchanged;
    }

    let ratio = diff::compute_change_ratio(&old_patch_view, &new_patch_view);

    if ratio > context.config.replace_threshold
        || context.current_pid >= context.config.max_patches_before_replace
    {
        let new_gen = context.current_gen + 1;
        let content = replace_gen::generate_replace(fid, new_gen, new_encoded, context.prefix);
        return VersionAction::Replace { generation: new_gen, content };
    }

    let hunks = diff::compute_line_diff(&old_patch_view, &new_patch_view);
    let new_pid = context.current_pid + 1;
    let content = patch_gen::generate_patch(
        fid,
        context.current_gen,
        new_pid,
        &hunks,
        context.prefix,
        context.anchor_interval,
    );
    VersionAction::Patch { pid: new_pid, content }
}

fn patch_view(encoded: &str, anchor_interval: u32) -> String {
    if anchor_interval > 0 {
        crate::encoding_layer::anchor::strip_anchor_prefixes(encoded)
    } else {
        encoded.to_string()
    }
}

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

    fn config() -> VersioningConfig {
        VersioningConfig::default()
    }

    #[test]
    fn test_unchanged() {
        let cfg = config();
        let action = determine_action(
            1,
            "same\n",
            "same\n",
            VersionContext {
                current_gen: 0,
                current_pid: 0,
                config: &cfg,
                prefix: "ctx",
                anchor_interval: 10,
            },
        );
        assert!(matches!(action, VersionAction::Unchanged));
    }

    #[test]
    fn test_small_change_produces_patch() {
        let old = "line1\nline2\nline3\nline4\nline5\n";
        let new = "line1\nmodified\nline3\nline4\nline5\n";
        let cfg = config();
        let action = determine_action(
            1,
            old,
            new,
            VersionContext {
                current_gen: 0,
                current_pid: 0,
                config: &cfg,
                prefix: "ctx",
                anchor_interval: 10,
            },
        );
        assert!(matches!(action, VersionAction::Patch { .. }));
    }

    #[test]
    fn test_large_change_produces_replace() {
        let old = "a\nb\nc\nd\ne\n";
        let new = "x\ny\nz\nw\nv\n";
        let mut cfg = config();
        cfg.replace_threshold = 0.3;
        let action = determine_action(
            1,
            old,
            new,
            VersionContext {
                current_gen: 0,
                current_pid: 0,
                config: &cfg,
                prefix: "ctx",
                anchor_interval: 10,
            },
        );
        assert!(matches!(action, VersionAction::Replace { .. }));
    }

    #[test]
    fn test_max_patches_triggers_replace() {
        let old = "line1\nline2\nline3\nline4\nline5\n";
        let new = "line1\nmodified\nline3\nline4\nline5\n";
        let mut cfg = config();
        cfg.max_patches_before_replace = 3;
        let action = determine_action(
            1,
            old,
            new,
            VersionContext {
                current_gen: 0,
                current_pid: 3,
                config: &cfg,
                prefix: "ctx",
                anchor_interval: 10,
            },
        );
        assert!(matches!(action, VersionAction::Replace { .. }));
    }

    #[test]
    fn test_patch_uses_content_lines_without_anchor_margin() {
        let old = "   1 | [0]fn main() {\n     | [4]old();\n     | [0]}\n";
        let new = "   1 | [0]fn main() {\n     | [4]new();\n     | [0]}\n";
        let mut cfg = config();
        cfg.replace_threshold = 1.0;

        let action = determine_action(
            1,
            old,
            new,
            VersionContext {
                current_gen: 0,
                current_pid: 0,
                config: &cfg,
                prefix: "ctx",
                anchor_interval: 10,
            },
        );

        match action {
            VersionAction::Patch { content, .. } => {
                assert!(content.contains("-[4]old();"));
                assert!(content.contains("+[4]new();"));
                assert!(!content.contains("| [4]old();"));
                assert!(!content.contains("| [4]new();"));
            }
            _ => panic!("expected patch"),
        }
    }
}