magi-code 0.63.2

Repository-aware CLI coding agent for terminal work
Documentation
use serde_json::{Value, json};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LineSelector {
    Single(usize),
    Range { start: usize, end: usize },
    FromStart { end: usize },
    FromLine { start: usize },
    Count { start: usize, count: usize },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ReadSelector {
    pub(crate) raw: bool,
    pub(crate) lines: Option<LineSelector>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SelectorSplit<'a> {
    pub(crate) target: &'a str,
    pub(crate) selector: ReadSelector,
}

impl ReadSelector {
    pub(crate) fn is_empty(self) -> bool {
        !self.raw && self.lines.is_none()
    }

    pub(crate) fn apply(self, text: &str) -> (String, Option<usize>, bool) {
        let Some(lines) = self.lines else {
            let total = count_lines(text);
            return (text.to_string(), Some(total), false);
        };
        let all = split_lines_lossless(text);
        let total = all.len();
        let (start, end) = match lines {
            LineSelector::Single(line) => (line.saturating_sub(1), line),
            LineSelector::Range { start, end } => (start.saturating_sub(1), end),
            LineSelector::FromStart { end } => (0, end),
            LineSelector::FromLine { start } => (start.saturating_sub(1), total),
            LineSelector::Count { start, count } => {
                let zero = start.saturating_sub(1);
                (zero, zero.saturating_add(count))
            }
        };
        let end = end.min(total);
        let selected = if start >= total || start >= end {
            String::new()
        } else {
            all[start..end].join("\n")
        };
        (selected, Some(total), end < total)
    }

    pub(crate) fn metadata(self) -> Value {
        if self.is_empty() {
            return Value::Null;
        }
        json!({
            "raw": self.raw,
            "lines": self.lines.map(|lines| match lines {
                LineSelector::Single(line) => json!({"type":"single", "line": line}),
                LineSelector::Range { start, end } => json!({"type":"range", "start": start, "end": end}),
                LineSelector::FromStart { end } => json!({"type":"from_start", "end": end}),
                LineSelector::FromLine { start } => json!({"type":"from_line", "start": start}),
                LineSelector::Count { start, count } => json!({"type":"count", "start": start, "count": count}),
            })
        })
    }
}

pub(crate) fn split_selector_suffix(input: &str) -> anyhow::Result<SelectorSplit<'_>> {
    let mut selector = ReadSelector::default();
    let mut target = input;
    while let Some((prefix, token)) = target.rsplit_once(':') {
        if prefix.is_empty() {
            break;
        }
        if token == "raw" {
            selector.raw = true;
            target = prefix;
            continue;
        }
        if selector.lines.is_none()
            && let Some(lines) = parse_line_selector(token)?
        {
            selector.lines = Some(lines);
            target = prefix;
            continue;
        }
        break;
    }
    Ok(SelectorSplit { target, selector })
}

fn parse_line_selector(token: &str) -> anyhow::Result<Option<LineSelector>> {
    if token.is_empty() {
        return Ok(None);
    }
    if let Some(end) = token.strip_prefix('-') {
        let end = parse_positive(end)?;
        return Ok(Some(LineSelector::FromStart { end }));
    }
    if let Some((start, rest)) = token.split_once('+') {
        if !is_digits(start) || (!rest.is_empty() && !is_digits(rest)) {
            return Ok(None);
        }
        let start = parse_positive(start)?;
        if rest.is_empty() {
            return Ok(Some(LineSelector::FromLine { start }));
        }
        let count = parse_positive(rest)?;
        return Ok(Some(LineSelector::Count { start, count }));
    }
    if let Some((start, end)) = token.split_once('-') {
        if !is_digits(start) || !is_digits(end) {
            return Ok(None);
        }
        let start = parse_positive(start)?;
        let end = parse_positive(end)?;
        if end < start {
            anyhow::bail!("read selector range end must be greater than or equal to start");
        }
        return Ok(Some(LineSelector::Range { start, end }));
    }
    if is_digits(token) {
        return Ok(Some(LineSelector::Single(parse_positive(token)?)));
    }
    Ok(None)
}

fn is_digits(value: &str) -> bool {
    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}

fn parse_positive(value: &str) -> anyhow::Result<usize> {
    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
        anyhow::bail!("invalid read selector line number '{value}'");
    }
    let parsed = value.parse::<usize>()?;
    if parsed == 0 {
        anyhow::bail!("read selector line numbers are 1-based");
    }
    Ok(parsed)
}

fn split_lines_lossless(text: &str) -> Vec<String> {
    if text.is_empty() {
        return Vec::new();
    }
    text.split_inclusive('\n')
        .map(|line| {
            let line = line.strip_suffix('\n').unwrap_or(line);
            line.strip_suffix('\r').unwrap_or(line).to_string()
        })
        .collect()
}

fn count_lines(text: &str) -> usize {
    split_lines_lossless(text).len()
}

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

    #[test]
    fn read_selector_parses_variants() {
        assert_eq!(
            split_selector_suffix("file:2").unwrap().selector.lines,
            Some(LineSelector::Single(2))
        );
        assert_eq!(
            split_selector_suffix("file:2-4").unwrap().selector.lines,
            Some(LineSelector::Range { start: 2, end: 4 })
        );
        assert_eq!(
            split_selector_suffix("file:-3").unwrap().selector.lines,
            Some(LineSelector::FromStart { end: 3 })
        );
        assert_eq!(
            split_selector_suffix("file:3+").unwrap().selector.lines,
            Some(LineSelector::FromLine { start: 3 })
        );
        assert_eq!(
            split_selector_suffix("file:3+2").unwrap().selector.lines,
            Some(LineSelector::Count { start: 3, count: 2 })
        );
        let parsed = split_selector_suffix("file:raw:2-3").unwrap();
        assert!(parsed.selector.raw);
        assert_eq!(
            parsed.selector.lines,
            Some(LineSelector::Range { start: 2, end: 3 })
        );
    }

    #[test]
    fn read_selector_applies_lines() {
        let text = "one\ntwo\nthree\nfour\n";
        assert_eq!(
            ReadSelector {
                raw: false,
                lines: Some(LineSelector::Single(2))
            }
            .apply(text)
            .0,
            "two"
        );
        assert_eq!(
            ReadSelector {
                raw: false,
                lines: Some(LineSelector::Range { start: 2, end: 3 })
            }
            .apply(text)
            .0,
            "two\nthree"
        );
        assert_eq!(
            ReadSelector {
                raw: false,
                lines: Some(LineSelector::FromStart { end: 2 })
            }
            .apply(text)
            .0,
            "one\ntwo"
        );
        assert_eq!(
            ReadSelector {
                raw: false,
                lines: Some(LineSelector::FromLine { start: 3 })
            }
            .apply(text)
            .0,
            "three\nfour"
        );
        assert_eq!(
            ReadSelector {
                raw: false,
                lines: Some(LineSelector::Count { start: 3, count: 1 })
            }
            .apply(text)
            .0,
            "three"
        );
    }
}