roder-tools 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use serde_json::{Value, json};

pub(crate) const DEFAULT_PAGE_LINES: usize = 200;
pub(crate) const MAX_PAGE_LINES: usize = 200;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LinePage {
    pub(crate) text: String,
    pub(crate) shown: usize,
    pub(crate) total: usize,
    pub(crate) next_offset: Option<usize>,
}

pub(crate) fn clamp_limit(limit: Option<usize>) -> usize {
    limit.unwrap_or(DEFAULT_PAGE_LINES).clamp(1, MAX_PAGE_LINES)
}

pub(crate) fn page_lines(lines: &[String], offset: usize, limit: usize) -> LinePage {
    let total = lines.len();
    let offset = offset.min(total);
    let end = offset.saturating_add(limit).min(total);
    let shown = end.saturating_sub(offset);
    let next_offset = (end < total).then_some(end);
    let mut text = lines[offset..end].join("\n");
    if let Some(next) = next_offset {
        if !text.is_empty() {
            text.push('\n');
        }
        text.push_str(&format!(
            "[showing lines {}-{} of {total}; next_offset={next}]",
            offset + 1,
            end
        ));
    }
    LinePage {
        text,
        shown,
        total,
        next_offset,
    }
}

pub(crate) fn omitted_lines(page: &LinePage) -> usize {
    page.next_offset
        .map(|next| page.total.saturating_sub(next))
        .unwrap_or(0)
}

pub(crate) fn append_continuation_instruction(
    text: &mut String,
    page: &LinePage,
    continuation_tool: &str,
    continuation_args: &Value,
) {
    if page.next_offset.is_none() {
        return;
    }

    if !text.is_empty() {
        text.push('\n');
    }
    let args_text = serde_json::to_string(continuation_args).unwrap_or_else(|_| "{}".to_string());
    text.push_str(&format!(
        "[truncated: {} more lines omitted. To continue, call {continuation_tool} with {args_text}.]",
        omitted_lines(page)
    ));
}

pub(crate) fn page_metadata(path: String, offset: usize, limit: usize, page: &LinePage) -> Value {
    json!({
        "path": path,
        "offset": offset,
        "limit": limit,
        "shown": page.shown,
        "total_lines": page.total,
        "omitted_lines": omitted_lines(page),
        "next_offset": page.next_offset,
        "truncated": page.next_offset.is_some(),
        "continuation_tool": Value::Null,
        "continuation_args": Value::Null,
    })
}

pub(crate) fn page_metadata_with_continuation(
    path: String,
    offset: usize,
    limit: usize,
    page: &LinePage,
    continuation_tool: &str,
    continuation_args: Value,
) -> Value {
    let mut data = page_metadata(path, offset, limit, page);
    if page.next_offset.is_some() {
        let Some(object) = data.as_object_mut() else {
            return data;
        };
        object.insert("continuation_tool".to_string(), json!(continuation_tool));
        object.insert("continuation_args".to_string(), continuation_args);
    }
    data
}

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

    #[test]
    fn page_lines_reports_next_offset() {
        let lines = (1..=5)
            .map(|line| format!("line {line}"))
            .collect::<Vec<_>>();
        let page = page_lines(&lines, 1, 2);

        assert_eq!(page.shown, 2);
        assert_eq!(page.next_offset, Some(3));
        assert_eq!(omitted_lines(&page), 2);
        assert!(page.text.contains("line 2\nline 3"));
        assert!(page.text.contains("next_offset=3"));
    }

    #[test]
    fn page_lines_appends_paging_continuation_instruction() {
        let lines = (1..=5)
            .map(|line| format!("line {line}"))
            .collect::<Vec<_>>();
        let mut text = page_lines(&lines, 0, 2).text;

        append_continuation_instruction(
            &mut text,
            &page_lines(&lines, 0, 2),
            "list_files",
            &json!({"path": ".", "offset": 2, "limit": 2}),
        );

        assert!(text.contains("3 more lines omitted"));
        assert!(text.contains("call list_files"));
        assert!(text.contains("\"offset\":2"));
    }

    #[test]
    fn page_metadata_includes_paging_continuation_args() {
        let lines = (1..=5)
            .map(|line| format!("line {line}"))
            .collect::<Vec<_>>();
        let page = page_lines(&lines, 0, 2);

        let metadata = page_metadata_with_continuation(
            ".".to_string(),
            0,
            2,
            &page,
            "glob",
            json!({"pattern": "*.rs", "offset": 2, "limit": 2}),
        );

        assert_eq!(metadata["truncated"], true);
        assert_eq!(metadata["omitted_lines"], 3);
        assert_eq!(metadata["continuation_tool"], "glob");
        assert_eq!(metadata["continuation_args"]["offset"], 2);
    }

    #[test]
    fn page_metadata_for_empty_paging_output_has_no_continuation() {
        let page = page_lines(&[], 0, 2);
        let metadata = page_metadata_with_continuation(
            ".".to_string(),
            0,
            2,
            &page,
            "glob",
            json!({"pattern": "*.rs", "offset": 2, "limit": 2}),
        );

        assert_eq!(page.text, "");
        assert_eq!(metadata["truncated"], false);
        assert_eq!(metadata["omitted_lines"], 0);
        assert!(metadata["continuation_tool"].is_null());
        assert!(metadata["continuation_args"].is_null());
    }
}