use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub struct Citation {
pub path: String,
pub line_range: String,
pub explanation: String,
}
fn parse_citations(text: &str) -> Vec<Citation> {
let inner = match extract_final_answer_block(text) {
Some(b) => b,
None => return Vec::new(),
};
let mut out = Vec::new();
for entry in inner.lines() {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some(c) = parse_citation_line(entry) {
out.push(c);
}
}
out
}
fn extract_final_answer_block(text: &str) -> Option<String> {
let start_tag = "<final_answer>";
let end_tag = "</final_answer>";
let start = text.find(start_tag)? + start_tag.len();
let rest = &text[start..];
let end = rest.find(end_tag)?;
Some(rest[..end].trim().to_string())
}
fn parse_citation_line(entry: &str) -> Option<Citation> {
let bytes = entry.as_bytes();
let mut idx = None;
for (i, &b) in bytes.iter().enumerate() {
if b == b':' && bytes.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
idx = Some(i);
break;
}
}
let colon = idx?;
let path = entry[..colon].trim().to_string();
let after = &entry[colon + 1..];
let mut end = 0;
let ab = after.as_bytes();
while end < ab.len() && ab[end].is_ascii_digit() {
end += 1;
}
if end < ab.len() && ab[end] == b'-' {
let mut j = end + 1;
while j < ab.len() && ab[j].is_ascii_digit() {
j += 1;
}
if j > end + 1 {
end = j;
}
}
if end == 0 {
return None;
}
let line_range = after[..end].to_string();
let explanation = after[end..].trim().to_string();
Some(Citation {
path,
line_range,
explanation,
})
}
fn format_citations(citations: &[Citation], root: &Path) -> String {
let mut formatted = Vec::new();
for c in citations {
let candidate = if Path::new(&c.path).is_absolute() {
root.join(c.path.trim_start_matches('/'))
} else {
root.join(&c.path)
};
let exists = Path::new(&c.path).is_file() || candidate.is_file();
if !exists {
continue;
}
if c.explanation.is_empty() {
formatted.push(format!("{}:{}", c.path, c.line_range));
} else {
formatted.push(format!("{}:{} {}", c.path, c.line_range, c.explanation));
}
}
format!("<final_answer>\n{}\n</final_answer>", formatted.join("\n"))
}
pub fn get_final_answer(text: &str, root: &Path) -> String {
let citations = parse_citations(text);
let validated = format_citations(&citations, root);
let preamble = match text.find("<final_answer>") {
Some(i) => text[..i].trim_end(),
None => text.trim_end(),
};
if preamble.is_empty() {
validated
} else {
format!("{preamble}\n\n{validated}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn parses_path_line_range_and_reason() {
let t = "prose\n<final_answer>\nsrc/a.rs:10-15 (core logic)\nsrc/b.rs:5\n</final_answer>";
let c = parse_citations(t);
assert_eq!(c.len(), 2);
assert_eq!(c[0].path, "src/a.rs");
assert_eq!(c[0].line_range, "10-15");
assert_eq!(c[0].explanation, "(core logic)");
assert_eq!(c[1].path, "src/b.rs");
assert_eq!(c[1].line_range, "5");
assert_eq!(c[1].explanation, "");
}
#[test]
fn no_block_means_no_citations() {
assert!(parse_citations("just prose, no answer block").is_empty());
}
#[test]
fn validation_drops_nonexistent_paths() {
let dir = std::env::temp_dir().join(format!("grove-ground-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("real.rs"), "fn x() {}\n").unwrap();
let text = "found it\n<final_answer>\nreal.rs:1-1 (here)\nghost.rs:9-9\n</final_answer>";
let out = get_final_answer(text, &dir);
assert!(out.contains("real.rs:1-1 (here)"), "kept real path: {out}");
assert!(!out.contains("ghost.rs"), "dropped hallucinated path: {out}");
assert!(out.starts_with("found it"), "kept prose preamble: {out}");
fs::remove_dir_all(&dir).ok();
}
}