ctxpack 0.1.0

Configuration-driven source packing for LLM context, with a versioning protocol for incremental patch/replace
use anyhow::Result;

use super::{ExtractionResult, Fragment};

#[derive(Debug, Clone)]
struct LineRange {
    start: u32,
    end: Option<u32>,
}

fn parse_ranges(ranges: &str) -> Result<Vec<LineRange>> {
    let mut result = Vec::new();
    for part in ranges.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        if let Some(dash_pos) = part.find('-') {
            let start_str = &part[..dash_pos];
            let end_str = &part[dash_pos + 1..];
            let start: u32 = start_str
                .trim()
                .parse()
                .map_err(|_| anyhow::anyhow!("invalid line range start: {}", start_str))?;
            let end = if end_str.trim().is_empty() {
                None
            } else {
                Some(
                    end_str
                        .trim()
                        .parse::<u32>()
                        .map_err(|_| anyhow::anyhow!("invalid line range end: {}", end_str))?,
                )
            };
            result.push(LineRange { start, end });
        } else {
            let n: u32 =
                part.parse().map_err(|_| anyhow::anyhow!("invalid line number: {}", part))?;
            result.push(LineRange { start: n, end: Some(n) });
        }
    }
    Ok(result)
}

pub fn extract_lines(content: &str, ranges: &str) -> Result<ExtractionResult> {
    let all_lines: Vec<&str> = content.lines().collect();
    let total_lines = all_lines.len() as u32;

    let parsed_ranges = parse_ranges(ranges)?;
    let mut fragments = Vec::new();

    for range in &parsed_ranges {
        let start = range.start.max(1);
        let end = range.end.unwrap_or(total_lines).min(total_lines);
        if start > total_lines || start > end {
            continue;
        }
        let mut frag_content = join_lines(&all_lines[(start - 1) as usize..end as usize]);
        if content.ends_with('\n') || range.end.is_none() {
            frag_content.push('\n');
        }
        fragments.push(Fragment { line_start: start, line_end: end, content: frag_content });
    }

    let covers_all = parsed_ranges.len() == 1
        && parsed_ranges[0].start == 1
        && parsed_ranges[0].end.map(|e| e >= total_lines).unwrap_or(true);

    Ok(ExtractionResult { fragments, total_lines, is_partial: !covers_all })
}

fn join_lines(lines: &[&str]) -> String {
    let len = lines.iter().map(|line| line.len()).sum::<usize>() + lines.len().saturating_sub(1);
    let mut out = String::with_capacity(len);
    for (i, line) in lines.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(line);
    }
    out
}

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

    fn make_content(n: u32) -> String {
        (1..=n).map(|i| format!("line{}", i)).collect::<Vec<_>>().join("\n") + "\n"
    }

    #[test]
    fn test_lines_1_5() {
        let content = make_content(10);
        let result = extract_lines(&content, "1-5").unwrap();
        assert_eq!(result.fragments.len(), 1);
        assert_eq!(result.fragments[0].line_start, 1);
        assert_eq!(result.fragments[0].line_end, 5);
        assert!(result.is_partial);
    }

    #[test]
    fn test_lines_two_ranges() {
        let content = make_content(10);
        let result = extract_lines(&content, "3-5,8-10").unwrap();
        assert_eq!(result.fragments.len(), 2);
        assert_eq!(result.fragments[0].line_start, 3);
        assert_eq!(result.fragments[0].line_end, 5);
        assert_eq!(result.fragments[1].line_start, 8);
        assert_eq!(result.fragments[1].line_end, 10);
    }

    #[test]
    fn test_lines_to_end() {
        let content = make_content(10);
        let result = extract_lines(&content, "5-").unwrap();
        assert_eq!(result.fragments.len(), 1);
        assert_eq!(result.fragments[0].line_start, 5);
        assert_eq!(result.fragments[0].line_end, 10);
    }

    #[test]
    fn test_parse_single_line() {
        let content = make_content(10);
        let result = extract_lines(&content, "3").unwrap();
        assert_eq!(result.fragments.len(), 1);
        assert_eq!(result.fragments[0].line_start, 3);
        assert_eq!(result.fragments[0].line_end, 3);
    }
}