ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
use std::path::{Component, Path};

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct IssueSnippetRequest {
    pub file: String,
    pub start: u32,
    pub end: u32,
}

pub(crate) fn collect_issue_snippet_requests(
    _issues: &[String],
    _workspace_root: &Path,
) -> Vec<IssueSnippetRequest> {
    let location_re = super::boundary_domain::issue_location_regex();
    let gh_location_re = super::boundary_domain::issue_gh_location_regex();

    let requests: std::collections::HashSet<IssueSnippetRequest> = _issues
        .iter()
        .filter_map(|issue| {
            let capture = location_re
                .captures(issue)
                .or_else(|| gh_location_re.captures(issue))?;
            let file = capture.name("file")?.as_str().trim().replace('\\', "/");
            let file = normalize_issue_file_path_to_workspace_relative(&file, _workspace_root)?;
            let start = capture.name("start")?.as_str().parse::<u32>().ok()?;
            let end = capture
                .name("end")
                .and_then(|m| m.as_str().parse::<u32>().ok())
                .unwrap_or(start);
            Some(IssueSnippetRequest { file, start, end })
        })
        .collect();

    requests.into_iter().collect()
}

pub(crate) fn normalize_issue_file_path_to_workspace_relative(
    _file: &str,
    _workspace_root: &Path,
) -> Option<String> {
    let trimmed = _file.trim();
    if trimmed.is_empty() || trimmed.starts_with("//") {
        return None;
    }

    let normalized = trimmed.replace('\\', "/");

    if is_safe_workspace_relative_path(&normalized) {
        return Some(normalized);
    }

    let path = Path::new(&normalized);

    if path.is_absolute() {
        let stripped = path.strip_prefix(_workspace_root).ok()?;
        let candidate = stripped.to_string_lossy().replace('\\', "/");
        return is_safe_workspace_relative_path(&candidate).then_some(candidate);
    }

    let bytes = normalized.as_bytes();
    if bytes.len() >= 2 && bytes[1] == b':' {
        let first = bytes[0] as char;
        if first.is_ascii_alphabetic() {
            let remainder = normalized[2..].trim_start_matches('/');
            let base = _workspace_root.file_name()?.to_str()?;
            let remainder = remainder.strip_prefix(base)?;
            let remainder = remainder.trim_start_matches('/');
            if remainder.is_empty() {
                return None;
            }

            let candidate = remainder.to_string();
            return is_safe_workspace_relative_path(&candidate).then_some(candidate);
        }
    }

    None
}

pub(crate) fn is_safe_workspace_relative_path(_path_str: &str) -> bool {
    let trimmed = _path_str.trim();
    if trimmed.is_empty() {
        return false;
    }

    let bytes = trimmed.as_bytes();
    if bytes.len() >= 2 && bytes[1] == b':' {
        let first = bytes[0] as char;
        if first.is_ascii_alphabetic() {
            return false;
        }
    }

    if trimmed.starts_with("//") {
        return false;
    }

    let path = Path::new(trimmed);
    if path.is_absolute() {
        return false;
    }

    !path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    })
}

pub(crate) fn extract_snippet_lines(_content: &str, _start: u32, _end: u32) -> Option<String> {
    if _start < 1 || _end < 1 || _end < _start {
        return None;
    }

    let lines: Vec<&str> = _content.lines().collect();
    if lines.is_empty() {
        return None;
    }

    let start_idx = _start.saturating_sub(1) as usize;
    if start_idx >= lines.len() {
        return None;
    }

    let end_idx = (_end.saturating_sub(1) as usize).min(lines.len().saturating_sub(1));

    let snippet = lines[start_idx..=end_idx]
        .iter()
        .enumerate()
        .map(|(offset, line)| {
            let line_no = u32::try_from(offset)
                .ok()
                .map(|offset| _start + offset)
                .unwrap_or(0);
            format!("{line_no} | {line}")
        })
        .collect::<Vec<_>>()
        .join("\n");

    Some(snippet)
}

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

    #[test]
    fn collect_issue_snippet_requests_parses_standard_and_github_locations() {
        let root = Path::new("/repo");
        let issues = vec![
            "src/lib.rs:10-12 problem".to_string(),
            "src/main.rs#L4-L5 something".to_string(),
        ];

        let requests = collect_issue_snippet_requests(&issues, root);
        assert_eq!(requests.len(), 2);
        assert!(requests
            .iter()
            .any(|r| { r.file == "src/lib.rs" && r.start == 10 && r.end == 12 }));
        assert!(requests
            .iter()
            .any(|r| { r.file == "src/main.rs" && r.start == 4 && r.end == 5 }));
    }

    #[test]
    fn normalize_issue_file_path_to_workspace_relative_handles_absolute_path() {
        let root = Path::new("/repo");
        let normalized = normalize_issue_file_path_to_workspace_relative("/repo/src/lib.rs", root);
        assert_eq!(normalized.as_deref(), Some("src/lib.rs"));
    }

    #[test]
    fn normalize_issue_file_path_to_workspace_relative_rejects_parent_dir_escape() {
        let root = Path::new("/repo");
        assert!(normalize_issue_file_path_to_workspace_relative("../secret", root).is_none());
    }

    #[test]
    fn is_safe_workspace_relative_path_rejects_windows_drive_path() {
        assert!(!is_safe_workspace_relative_path("C:/repo/src/lib.rs"));
    }

    #[test]
    fn extract_snippet_lines_formats_expected_line_numbers() {
        let content = "one\ntwo\nthree\n";
        let snippet = extract_snippet_lines(content, 2, 3).expect("snippet");
        assert_eq!(snippet, "2 | two\n3 | three");
    }
}