use crate::utils::file_parser::{
parse_file_references, read_multiple_files, FileContent, LineRange,
};
use regex::Regex;
use std::collections::HashMap;
use std::sync::LazyLock;
static CONTEXT_EXTRACT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<context>(.*?)</context>").expect("Failed to compile context extraction regex")
});
pub fn expand_context_blocks(text: &str) -> String {
let mut result = text.to_string();
let matches: Vec<_> = CONTEXT_EXTRACT_REGEX.find_iter(text).collect();
for context_match in matches.iter().rev() {
let full_match = context_match.as_str();
if let Some(captures) = CONTEXT_EXTRACT_REGEX.captures(full_match) {
if let Some(context_content) = captures.get(1) {
let file_refs_text = context_content.as_str();
let file_refs = parse_file_references(file_refs_text);
if !file_refs.is_empty() {
let file_contents = read_multiple_files(&file_refs);
let expanded_content = render_files_as_xml(&file_contents);
let start = context_match.start();
let end = context_match.end();
result.replace_range(start..end, &expanded_content);
} else {
let start = context_match.start();
let end = context_match.end();
result.replace_range(start..end, "");
}
}
}
}
result
}
#[derive(Debug, Clone, PartialEq)]
pub enum RenderFormat {
Xml,
Text,
}
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub format: RenderFormat,
pub show_line_numbers: bool,
pub include_header: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
format: RenderFormat::Xml,
show_line_numbers: true,
include_header: true,
}
}
}
pub fn render_files_as_xml(file_contents: &HashMap<String, Vec<FileContent>>) -> String {
let options = RenderOptions {
format: RenderFormat::Xml,
..Default::default()
};
render_files_with_options(file_contents, &options)
}
pub fn render_files_as_text(file_contents: &HashMap<String, Vec<FileContent>>) -> String {
let options = RenderOptions {
format: RenderFormat::Text,
..Default::default()
};
render_files_with_options(file_contents, &options)
}
pub fn render_files_with_options(
file_contents: &HashMap<String, Vec<FileContent>>,
options: &RenderOptions,
) -> String {
if file_contents.is_empty() {
return "No specific file context requested.".to_string();
}
let mut result = String::new();
if options.include_header {
result.push_str("FILE CONTEXT:\n\n");
}
let mut sorted_files: Vec<_> = file_contents.iter().collect();
sorted_files.sort_by_key(|(path, _)| *path);
for (_filepath, contents) in sorted_files {
for content in contents {
match options.format {
RenderFormat::Xml => {
render_single_file_xml(&mut result, content);
}
RenderFormat::Text => {
render_single_file_text(&mut result, content);
}
}
}
}
result
}
fn render_single_file_xml(result: &mut String, content: &FileContent) {
if let Some(error) = &content.error {
result.push_str(&format!(
"<content path=\"{}\" lines=\"{}:{}\" error=\"true\">\n{}\n</content>\n\n",
xml_escape(&content.path),
content.line_range.start,
content.line_range.end,
xml_escape(error)
));
} else {
let lines_str = if content.line_range.start == content.line_range.end {
content.line_range.start.to_string()
} else {
format!("{}:{}", content.line_range.start, content.line_range.end)
};
result.push_str(&format!(
"<content path=\"{}\" lines=\"{}\">\n",
xml_escape(&content.path),
lines_str
));
for line in &content.lines {
result.push_str(&xml_escape(line));
result.push('\n');
}
result.push_str("</content>\n\n");
}
}
fn render_single_file_text(result: &mut String, content: &FileContent) {
result.push_str(&format!(
"=== {} (lines {}-{}) ===\n",
content.path, content.line_range.start, content.line_range.end
));
if let Some(error) = &content.error {
result.push_str(&format!("// {}\n", error));
} else {
for line in &content.lines {
result.push_str(line);
result.push('\n');
}
}
result.push('\n');
}
fn xml_escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn merge_line_ranges(ranges: &[LineRange]) -> Vec<LineRange> {
if ranges.is_empty() {
return Vec::new();
}
let mut sorted_ranges = ranges.to_vec();
sorted_ranges.sort_by_key(|r| r.start);
let mut merged = Vec::new();
let mut current = sorted_ranges[0].clone();
for range in sorted_ranges.iter().skip(1) {
if range.start <= current.end + 5 {
current.end = current.end.max(range.end);
} else {
merged.push(current);
current = range.clone();
}
}
merged.push(current);
merged
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::file_parser::{FileContent, LineRange};
use std::collections::HashMap;
fn create_test_file_content(
path: &str,
start: usize,
end: usize,
lines: Vec<&str>,
error: Option<&str>,
) -> FileContent {
FileContent {
path: path.to_string(),
lines: lines.into_iter().map(|s| s.to_string()).collect(),
line_range: LineRange::new(start, end).unwrap(),
error: error.map(|s| s.to_string()),
}
}
#[test]
fn test_expand_context_blocks() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
writeln!(temp_file, "line 1").expect("Failed to write to temp file");
writeln!(temp_file, "line 2").expect("Failed to write to temp file");
writeln!(temp_file, "line 3").expect("Failed to write to temp file");
writeln!(temp_file, "line 4").expect("Failed to write to temp file");
writeln!(temp_file, "line 5").expect("Failed to write to temp file");
temp_file.flush().expect("Failed to flush temp file");
let temp_path = temp_file.path().to_string_lossy().to_string();
let input = format!(
"Some text before\n<context>\n{}:1:2\n</context>\nSome text after",
temp_path
);
let result = expand_context_blocks(&input);
#[cfg(debug_assertions)]
{
eprintln!("Input: {}", input);
eprintln!("Result: {}", result);
eprintln!("Temp path: {}", temp_path);
}
assert!(
result.contains("Some text before"),
"Result should contain 'Some text before'. Result: {}",
result
);
assert!(
result.contains("Some text after"),
"Result should contain 'Some text after'. Result: {}",
result
);
assert!(
result.contains("<content path="),
"Result should contain '<content path='. Result: {}",
result
);
assert!(
result.contains("lines=\"1:2\""),
"Result should contain 'lines=\"1:2\"'. Result: {}",
result
);
let has_line1 = result.contains("1: line 1") || result.contains("1: line 1\r");
let has_line2 = result.contains("2: line 2") || result.contains("2: line 2\r");
let has_line3 = result.contains("3: line 3") || result.contains("3: line 3\r");
assert!(
has_line1,
"Result should contain '1: line 1'. Result: {}",
result
);
assert!(
has_line2,
"Result should contain '2: line 2'. Result: {}",
result
);
assert!(
!has_line3,
"Result should not contain '3: line 3'. Result: {}",
result
);
assert!(
!result.contains("<context>"),
"Result should not contain '<context>'. Result: {}",
result
);
assert!(
!result.contains("</context>"),
"Result should not contain '</context>'. Result: {}",
result
);
let input_multi = format!(
"Text1\n<context>\n{}:1:1\n</context>\nText2\n<context>\n{}:3:4\n</context>\nText3",
temp_path, temp_path
);
let result_multi = expand_context_blocks(&input_multi);
assert!(result_multi.contains("Text1"));
assert!(result_multi.contains("Text2"));
assert!(result_multi.contains("Text3"));
let multi_has_line1 =
result_multi.contains("1: line 1") || result_multi.contains("1: line 1\r");
let multi_has_line2 =
result_multi.contains("2: line 2") || result_multi.contains("2: line 2\r");
let multi_has_line3 =
result_multi.contains("3: line 3") || result_multi.contains("3: line 3\r");
let multi_has_line4 =
result_multi.contains("4: line 4") || result_multi.contains("4: line 4\r");
assert!(
multi_has_line1,
"Multi result should contain '1: line 1'. Result: {}",
result_multi
);
assert!(
multi_has_line3,
"Multi result should contain '3: line 3'. Result: {}",
result_multi
);
assert!(
multi_has_line4,
"Multi result should contain '4: line 4'. Result: {}",
result_multi
);
assert!(
!multi_has_line2,
"Multi result should not contain '2: line 2'. Result: {}",
result_multi
);
let input_empty = "Text before <context></context> Text after";
let result_empty = expand_context_blocks(input_empty);
assert_eq!(result_empty, "Text before Text after");
let input_none = "No context blocks here";
let result_none = expand_context_blocks(input_none);
assert_eq!(result_none, input_none);
let input_invalid = "Text <context>nonexistent.rs:1:5</context> More";
let result_invalid = expand_context_blocks(input_invalid);
assert!(result_invalid.contains("Text "));
assert!(result_invalid.contains(" More"));
assert!(result_invalid.contains("error=\"true\""));
}
#[test]
fn test_render_files_as_xml() {
let mut file_contents = HashMap::new();
let content = create_test_file_content(
"src/main.rs",
1,
3,
vec!["1: fn main() {", "2: println!(\"Hello\");", "3: }"],
None,
);
file_contents.insert("src/main.rs".to_string(), vec![content]);
let result = render_files_as_xml(&file_contents);
println!("Actual result:\n{}", result);
assert!(result.contains("FILE CONTEXT:"));
assert!(result.contains("<content path=\"src/main.rs\" lines=\"1:3\">"));
assert!(result.contains("1: fn main() {"));
assert!(result.contains("2: println!("Hello");"));
assert!(result.contains("3: }"));
assert!(result.contains("</content>"));
}
#[test]
fn test_render_files_as_text() {
let mut file_contents = HashMap::new();
let content = create_test_file_content(
"src/main.rs",
1,
3,
vec!["1: fn main() {", "2: println!(\"Hello\");", "3: }"],
None,
);
file_contents.insert("src/main.rs".to_string(), vec![content]);
let result = render_files_as_text(&file_contents);
assert!(result.contains("FILE CONTEXT:"));
assert!(result.contains("=== src/main.rs (lines 1-3) ==="));
assert!(result.contains("1: fn main() {"));
assert!(result.contains("2: println!(\"Hello\");"));
assert!(result.contains("3: }"));
}
#[test]
fn test_xml_escaping() {
let mut file_contents = HashMap::new();
let content = create_test_file_content(
"src/test.rs",
1,
1,
vec!["1: let x = \"<test>\" & 'value';"],
None,
);
file_contents.insert("src/test.rs".to_string(), vec![content]);
let result = render_files_as_xml(&file_contents);
assert!(result.contains("<test>"));
assert!(result.contains("&"));
assert!(result.contains("'value'"));
assert!(result.contains("""));
}
#[test]
fn test_render_error_xml() {
let mut file_contents = HashMap::new();
let content = create_test_file_content(
"missing.rs",
1,
10,
vec![],
Some("File not found: missing.rs"),
);
file_contents.insert("missing.rs".to_string(), vec![content]);
let result = render_files_as_xml(&file_contents);
assert!(result.contains("<content path=\"missing.rs\" lines=\"1:10\" error=\"true\">"));
assert!(result.contains("File not found: missing.rs"));
assert!(result.contains("</content>"));
}
#[test]
fn test_render_error_text() {
let mut file_contents = HashMap::new();
let content = create_test_file_content(
"missing.rs",
1,
10,
vec![],
Some("File not found: missing.rs"),
);
file_contents.insert("missing.rs".to_string(), vec![content]);
let result = render_files_as_text(&file_contents);
assert!(result.contains("=== missing.rs (lines 1-10) ==="));
assert!(result.contains("// File not found: missing.rs"));
}
#[test]
fn test_multiple_files_sorted() {
let mut file_contents = HashMap::new();
let content1 = create_test_file_content("z_file.rs", 1, 1, vec!["1: last"], None);
let content2 = create_test_file_content("a_file.rs", 1, 1, vec!["1: first"], None);
file_contents.insert("z_file.rs".to_string(), vec![content1]);
file_contents.insert("a_file.rs".to_string(), vec![content2]);
let result = render_files_as_xml(&file_contents);
let a_pos = result.find("a_file.rs").unwrap();
let z_pos = result.find("z_file.rs").unwrap();
assert!(a_pos < z_pos);
}
#[test]
fn test_single_line_range() {
let mut file_contents = HashMap::new();
let content = create_test_file_content("test.rs", 5, 5, vec!["5: single line"], None);
file_contents.insert("test.rs".to_string(), vec![content]);
let result = render_files_as_xml(&file_contents);
assert!(result.contains("lines=\"5\""));
assert!(!result.contains("lines=\"5:5\""));
}
#[test]
fn test_merge_line_ranges() {
let ranges = vec![
LineRange::new(1, 5).unwrap(),
LineRange::new(3, 8).unwrap(), LineRange::new(10, 15).unwrap(), LineRange::new(25, 30).unwrap(), ];
let merged = merge_line_ranges(&ranges);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0], LineRange::new(1, 15).unwrap()); assert_eq!(merged[1], LineRange::new(25, 30).unwrap()); }
#[test]
fn test_render_with_custom_options() {
let mut file_contents = HashMap::new();
let content = create_test_file_content("test.rs", 1, 2, vec!["1: line1", "2: line2"], None);
file_contents.insert("test.rs".to_string(), vec![content]);
let options = RenderOptions {
format: RenderFormat::Xml,
show_line_numbers: true,
include_header: false,
};
let result = render_files_with_options(&file_contents, &options);
assert!(!result.contains("FILE CONTEXT:"));
assert!(result.contains("<content path=\"test.rs\""));
}
#[test]
fn test_empty_file_contents() {
let file_contents = HashMap::new();
let result = render_files_as_xml(&file_contents);
assert_eq!(result, "No specific file context requested.");
}
}