agent-source-repository 0.1.0

Agent Source Repository local context registry for coding agents
Documentation
//! Pure read planning for ASR file reads.
//!
//! This module keeps line-range state and read-window validation independent
//! from repository resolution, filesystem access, hashing, and DTO assembly.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LineRange {
    start: usize,
    end: usize,
}

impl LineRange {
    fn new(start: usize, end: usize) -> Result<Self, LineRangeParseError> {
        if start == 0 || end == 0 || end < start {
            return Err(LineRangeParseError::NonPositiveOrReversed);
        }
        Ok(Self { start, end })
    }

    pub(crate) fn start(self) -> usize {
        self.start
    }

    pub(crate) fn end(self) -> usize {
        self.end
    }

    pub(crate) fn line_count(self) -> usize {
        self.end - self.start + 1
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LineRangeParseError {
    MissingSeparator,
    InvalidStart,
    InvalidEnd,
    NonPositiveOrReversed,
}

pub(crate) fn parse_line_range(value: &str) -> Result<LineRange, LineRangeParseError> {
    let Some((start, end)) = value.split_once(':') else {
        return Err(LineRangeParseError::MissingSeparator);
    };
    let start = start
        .parse::<usize>()
        .map_err(|_| LineRangeParseError::InvalidStart)?;
    let end = end
        .parse::<usize>()
        .map_err(|_| LineRangeParseError::InvalidEnd)?;
    LineRange::new(start, end)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReadPlan {
    range: LineRange,
    total_lines: usize,
}

impl ReadPlan {
    pub(crate) fn start_line(self) -> usize {
        self.range.start()
    }

    pub(crate) fn end_line(self) -> usize {
        self.range.end()
    }

    pub(crate) fn requested_start_line(self) -> usize {
        self.range.start()
    }

    pub(crate) fn requested_end_line(self) -> usize {
        self.range.end()
    }

    pub(crate) fn line_count(self) -> usize {
        self.range.line_count()
    }

    pub(crate) fn total_lines(self) -> usize {
        self.total_lines
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadPlanError {
    RangeTooLarge {
        max_lines: usize,
    },
    RangeOutsideFile {
        requested_start_line: usize,
        requested_end_line: usize,
        total_lines: usize,
    },
}

pub(crate) fn plan_read(
    range: LineRange,
    total_lines: usize,
    max_lines: usize,
) -> Result<ReadPlan, ReadPlanError> {
    if range.line_count() > max_lines {
        return Err(ReadPlanError::RangeTooLarge { max_lines });
    }
    if total_lines == 0 || range.start() > total_lines || range.end() > total_lines {
        return Err(ReadPlanError::RangeOutsideFile {
            requested_start_line: range.start(),
            requested_end_line: range.end(),
            total_lines,
        });
    }
    Ok(ReadPlan { range, total_lines })
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReadLineSlice {
    pub(crate) line: usize,
    pub(crate) content: String,
}

pub(crate) fn collect_read_lines(source: &str, plan: ReadPlan) -> Vec<ReadLineSlice> {
    source
        .lines()
        .enumerate()
        .skip(plan.start_line() - 1)
        .take(plan.line_count())
        .map(|(index, content)| ReadLineSlice {
            line: index + 1,
            content: content.to_string(),
        })
        .collect()
}

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

    #[test]
    fn parse_line_range_accepts_positive_ordered_bounds() {
        let range = parse_line_range("2:4").unwrap();

        assert_eq!(range.start(), 2);
        assert_eq!(range.end(), 4);
        assert_eq!(range.line_count(), 3);
    }

    #[test]
    fn parse_line_range_rejects_invalid_shapes() {
        assert_eq!(
            parse_line_range("2").unwrap_err(),
            LineRangeParseError::MissingSeparator
        );
        assert_eq!(
            parse_line_range("x:2").unwrap_err(),
            LineRangeParseError::InvalidStart
        );
        assert_eq!(
            parse_line_range("1:y").unwrap_err(),
            LineRangeParseError::InvalidEnd
        );
        assert_eq!(
            parse_line_range("0:2").unwrap_err(),
            LineRangeParseError::NonPositiveOrReversed
        );
        assert_eq!(
            parse_line_range("3:2").unwrap_err(),
            LineRangeParseError::NonPositiveOrReversed
        );
    }

    #[test]
    fn plan_read_rejects_ranges_larger_than_policy_before_file_bounds() {
        let range = parse_line_range("1:5").unwrap();

        assert_eq!(
            plan_read(range, 2, 3).unwrap_err(),
            ReadPlanError::RangeTooLarge { max_lines: 3 }
        );
    }

    #[test]
    fn plan_read_rejects_ranges_outside_file() {
        let range = parse_line_range("2:4").unwrap();

        assert_eq!(
            plan_read(range, 3, 10).unwrap_err(),
            ReadPlanError::RangeOutsideFile {
                requested_start_line: 2,
                requested_end_line: 4,
                total_lines: 3,
            }
        );
    }

    #[test]
    fn collect_read_lines_returns_the_planned_slice() {
        let range = parse_line_range("2:3").unwrap();
        let plan = plan_read(range, 4, 10).unwrap();
        let lines = collect_read_lines("one\ntwo\nthree\nfour\n", plan);

        assert_eq!(
            lines,
            vec![
                ReadLineSlice {
                    line: 2,
                    content: "two".to_string(),
                },
                ReadLineSlice {
                    line: 3,
                    content: "three".to_string(),
                },
            ]
        );
    }
}