use crate::analysis::parse::parse_label;
use crate::review::diff::{ChangeStatus, ChangedFile, DiffTarget, git_show};
use crate::scan::language::detect_language;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
use tree_sitter::Tree;
pub struct ReviewSource {
content: String,
language_label: Option<String>,
tree: OnceLock<Option<Tree>>,
}
impl ReviewSource {
#[cfg(test)]
pub(crate) fn new(content: String, language_label: Option<String>) -> Self {
Self {
content,
language_label,
tree: OnceLock::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
pub fn language_label(&self) -> Option<&str> {
self.language_label.as_deref()
}
pub fn tree(&self) -> Option<&Tree> {
self.tree
.get_or_init(|| {
self.language_label
.as_deref()
.and_then(|label| parse_label(&self.content, label))
})
.as_ref()
}
}
fn source_from(content: String, path: &Path) -> ReviewSource {
ReviewSource {
content,
language_label: detect_language(path).map(str::to_string),
tree: OnceLock::new(),
}
}
pub fn post_change_source(
repo_root: &Path,
file: &ChangedFile,
target: DiffTarget<'_>,
) -> Option<ReviewSource> {
if file.status == ChangeStatus::Deleted {
return None;
}
let content = match target {
DiffTarget::WorkingTree | DiffTarget::SinceRef { .. } => {
fs::read_to_string(repo_root.join(&file.path)).ok()?
}
DiffTarget::Refs { head, .. } => git_show(repo_root, head, &file.path_string())?,
};
Some(source_from(content, &file.path))
}
pub fn pre_change_source(
repo_root: &Path,
file: &ChangedFile,
target: DiffTarget<'_>,
) -> Option<ReviewSource> {
if matches!(file.status, ChangeStatus::Added | ChangeStatus::Untracked) {
return None;
}
let reference = match target {
DiffTarget::WorkingTree => "HEAD",
DiffTarget::Refs { base, .. } | DiffTarget::SinceRef { base } => base,
};
let content = git_show(repo_root, reference, &file.path_string())?;
Some(source_from(content, &file.path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::review::diff::{ChangeStatus, ChangedFile};
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("failed to run git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo(root: &Path) {
git(root, &["init"]);
git(root, &["config", "user.email", "repopilot@example.invalid"]);
git(root, &["config", "user.name", "RepoPilot Test"]);
}
fn write(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, content).expect("write file");
}
fn changed(path: &str, status: ChangeStatus) -> ChangedFile {
ChangedFile {
path: PathBuf::from(path),
status,
ranges: Vec::new(),
hunks: Vec::new(),
}
}
#[test]
fn working_tree_reads_worktree_after_and_head_before() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
init_repo(root);
write(root, "src/app.rs", "fn old() {}\n");
git(root, &["add", "."]);
git(root, &["commit", "-m", "initial"]);
write(root, "src/app.rs", "fn updated() {}\n");
let file = changed("src/app.rs", ChangeStatus::Modified);
let before = pre_change_source(root, &file, DiffTarget::WorkingTree).expect("before");
let after = post_change_source(root, &file, DiffTarget::WorkingTree).expect("after");
assert_eq!(before.content(), "fn old() {}\n");
assert_eq!(after.content(), "fn updated() {}\n");
assert!(before.tree().is_some());
assert!(after.tree().is_some());
}
#[test]
fn deleted_file_has_before_but_no_after() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
init_repo(root);
write(root, "src/gone.rs", "fn gone() {}\n");
git(root, &["add", "."]);
git(root, &["commit", "-m", "initial"]);
fs::remove_file(root.join("src/gone.rs")).expect("remove file");
let file = changed("src/gone.rs", ChangeStatus::Deleted);
let before = pre_change_source(root, &file, DiffTarget::WorkingTree).expect("before");
assert_eq!(before.content(), "fn gone() {}\n");
assert!(post_change_source(root, &file, DiffTarget::WorkingTree).is_none());
}
#[test]
fn added_file_has_after_but_no_before() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
init_repo(root);
write(root, "src/seed.rs", "fn seed() {}\n");
git(root, &["add", "."]);
git(root, &["commit", "-m", "initial"]);
write(root, "src/added.rs", "fn brand_new() {}\n");
let file = changed("src/added.rs", ChangeStatus::Untracked);
assert!(pre_change_source(root, &file, DiffTarget::WorkingTree).is_none());
let after = post_change_source(root, &file, DiffTarget::WorkingTree).expect("after");
assert_eq!(after.content(), "fn brand_new() {}\n");
}
#[test]
fn ref_range_reads_blobs_at_base_and_head() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
init_repo(root);
write(root, "src/app.rs", "fn v1() {}\n");
git(root, &["add", "."]);
git(root, &["commit", "-m", "v1"]);
write(root, "src/app.rs", "fn v2() {}\n");
git(root, &["add", "."]);
git(root, &["commit", "-m", "v2"]);
let file = changed("src/app.rs", ChangeStatus::Modified);
let target = DiffTarget::Refs {
base: "HEAD~1",
head: "HEAD",
};
let before = pre_change_source(root, &file, target).expect("before");
let after = post_change_source(root, &file, target).expect("after");
assert_eq!(before.content(), "fn v1() {}\n");
assert_eq!(after.content(), "fn v2() {}\n");
}
}