use std::path::PathBuf;
use crate::ai::types::PrDetails;
use crate::config::ReviewConfig;
#[cfg(all(feature = "ast-context", feature = "graph"))]
use regex::Regex;
#[cfg(all(feature = "ast-context", feature = "graph"))]
use std::sync::LazyLock;
#[cfg(all(feature = "ast-context", feature = "graph"))]
static SYMBOL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\+(\s*)(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(fn|struct|enum|trait|impl)\s+([a-zA-Z_]\w*)",
)
.expect("valid SYMBOL_RE")
});
pub(crate) const PROMPT_OVERHEAD_CHARS: usize = 1_000;
#[derive(Clone, Debug)]
pub struct ReviewContext {
pub pr: PrDetails,
pub ast_context: String,
pub call_graph: String,
pub inferred_repo_path: Option<PathBuf>,
pub cwd_inferred: bool,
pub max_chars_per_file: usize,
pub max_diff_chars: usize,
pub max_patch_chars_per_file: usize,
pub files_truncated: usize,
pub truncated_chars_dropped: usize,
pub files_total: usize,
pub files_with_patch: usize,
pub dep_enrichments_count: usize,
pub dep_enrichments_chars: usize,
pub budget_drops: Vec<String>,
pub prompt_chars_final: usize,
pub estimated_size: usize,
pub graph_context: String,
pub graph_cache_hit: bool,
}
impl ReviewContext {
#[must_use]
pub fn verbose_summary(&self) -> String {
use std::fmt::Write;
let mut summary = String::new();
if let Some(path) = &self.inferred_repo_path {
let inferred_label = if self.cwd_inferred { " (inferred)" } else { "" };
let _ = writeln!(
summary,
"Repository path: {}{}",
path.display(),
inferred_label
);
}
if !self.pr.dep_enrichments.is_empty() {
let packages: Vec<&str> = self
.pr
.dep_enrichments
.iter()
.map(|d| d.package_name.as_str())
.collect();
let _ = writeln!(summary, "Dependency enrichments: {}", packages.join(", "));
}
let mut context_sizes = Vec::new();
if !self.ast_context.is_empty() {
context_sizes.push(format!("AST: {} chars", self.ast_context.len()));
}
if !self.call_graph.is_empty() {
context_sizes.push(format!("call graph: {} chars", self.call_graph.len()));
}
if !context_sizes.is_empty() {
let _ = writeln!(summary, "Context: {}", context_sizes.join(", "));
}
if self.files_truncated > 0 {
let _ = writeln!(
summary,
"Files truncated: {} ({} chars dropped)",
self.files_truncated, self.truncated_chars_dropped
);
}
summary
}
pub fn record_truncation(&mut self, filename: &str, original_len: usize, truncated_len: usize) {
self.files_truncated += 1;
self.truncated_chars_dropped += original_len - truncated_len;
tracing::debug!(
filename = %filename,
original_len,
truncated_len,
"file content truncated at prompt assembly"
);
}
}
impl Default for ReviewContext {
fn default() -> Self {
Self {
pr: crate::ai::types::PrDetails {
owner: String::new(),
repo: String::new(),
number: 0,
title: String::new(),
body: String::new(),
base_branch: String::new(),
head_branch: String::new(),
files: Vec::new(),
url: String::new(),
labels: Vec::new(),
head_sha: String::new(),
review_comments: Vec::new(),
instructions: None,
dep_enrichments: Vec::new(),
},
ast_context: String::new(),
call_graph: String::new(),
inferred_repo_path: None,
cwd_inferred: false,
max_chars_per_file: crate::config::ReviewConfig::default().max_chars_per_file,
max_diff_chars: crate::config::ReviewConfig::default().max_diff_chars,
max_patch_chars_per_file: crate::config::ReviewConfig::default()
.max_patch_chars_per_file,
files_truncated: 0,
truncated_chars_dropped: 0,
files_total: 0,
files_with_patch: 0,
dep_enrichments_count: 0,
dep_enrichments_chars: 0,
budget_drops: Vec::new(),
prompt_chars_final: 0,
estimated_size: 0,
graph_context: String::new(),
graph_cache_hit: false,
}
}
}
pub async fn build_review_context(
mut pr: PrDetails,
repo_path: Option<String>,
deep: bool,
review_config: &ReviewConfig,
graph_config: &crate::config::GraphConfig,
) -> crate::Result<ReviewContext> {
#[cfg(not(target_arch = "wasm32"))]
let (inferred_repo_path, cwd_inferred) = resolve_repo_path(&pr, repo_path);
#[cfg(target_arch = "wasm32")]
let (inferred_repo_path, cwd_inferred) = (repo_path.map(std::path::PathBuf::from), false);
let repo_path_ref = inferred_repo_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
#[cfg(feature = "ast-context")]
let ast_output = build_ctx_ast(repo_path_ref.as_deref(), &pr.files).await;
#[cfg(feature = "ast-context")]
let ast_context = ast_output.text.clone();
#[cfg(not(feature = "ast-context"))]
let ast_output = build_ctx_ast(repo_path_ref.as_deref(), &pr.files).await;
#[cfg(not(feature = "ast-context"))]
let ast_context = ast_output.clone();
pr.dep_enrichments = enrich_deps(&pr.files, review_config).await;
let estimated_size = estimate_pr_size(&pr, &ast_context, "", "");
let max_prompt_chars = review_config.max_prompt_chars;
let budget_remaining = max_prompt_chars.saturating_sub(estimated_size);
let should_enable_cg = should_enable_call_graph(deep, budget_remaining, review_config);
let mut call_graph = if should_enable_cg {
build_ctx_call_graph(repo_path_ref.as_deref(), &pr.files, true).await
} else {
String::new()
};
let final_estimated_size = estimate_pr_size(&pr, &ast_context, &call_graph, "");
let (mut graph_context, graph_cache_hit) =
build_ctx_graph(graph_config, repo_path_ref.as_deref(), &pr, &ast_output).await;
let mut ast_context = ast_context;
let mut budget_drops = Vec::new();
apply_budget_drops(
&mut pr,
&mut ast_context,
&mut call_graph,
&mut graph_context,
deep,
max_prompt_chars,
&mut budget_drops,
);
let files_total = pr.files.len();
let files_with_patch = pr
.files
.iter()
.filter(|f| f.patch.as_deref().is_some_and(|p| !p.is_empty()))
.count();
let dep_enrichments_count = pr.dep_enrichments.len();
let dep_enrichments_chars = pr
.dep_enrichments
.iter()
.map(|d| serde_json::to_string(d).unwrap_or_default().len())
.sum();
Ok(ReviewContext {
pr,
ast_context,
call_graph,
inferred_repo_path,
cwd_inferred,
max_chars_per_file: review_config.max_chars_per_file,
max_diff_chars: review_config.max_diff_chars,
max_patch_chars_per_file: review_config.max_patch_chars_per_file,
files_truncated: 0,
truncated_chars_dropped: 0,
files_total,
files_with_patch,
dep_enrichments_count,
dep_enrichments_chars,
budget_drops,
prompt_chars_final: 0,
estimated_size: final_estimated_size,
graph_context,
graph_cache_hit,
})
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_repo_path(
pr: &PrDetails,
explicit_repo_path: Option<String>,
) -> (Option<PathBuf>, bool) {
if explicit_repo_path.is_some() {
(explicit_repo_path.map(PathBuf::from), false)
} else if let Some(inferred_path) = infer_repo_path_from_cwd(&pr.owner, &pr.repo) {
(Some(PathBuf::from(&inferred_path)), true)
} else {
(None, false)
}
}
fn should_enable_call_graph(deep: bool, budget_remaining: usize, config: &ReviewConfig) -> bool {
deep || budget_remaining > config.min_budget_for_call_graph
}
async fn enrich_deps(
files: &[crate::ai::types::PrFile],
config: &ReviewConfig,
) -> Vec<crate::ai::types::DepReleaseNote> {
crate::ai::dep_enrichment::enrich_dep_releases(
files,
config.max_dep_packages,
config.max_dep_release_chars,
)
.await
}
fn apply_budget_drops(
pr: &mut PrDetails,
ast_context: &mut String,
call_graph: &mut String,
graph_context: &mut String,
deep: bool,
max_prompt_chars: usize,
budget_drops: &mut Vec<String>,
) {
let mut estimated_size = estimate_pr_size(pr, ast_context, call_graph, graph_context);
if estimated_size > max_prompt_chars && !deep {
tracing::warn!(
section = "call_graph",
chars = call_graph.len(),
"Dropping section: prompt budget exceeded"
);
let dropped_chars = call_graph.len();
call_graph.clear();
estimated_size -= dropped_chars;
budget_drops.push("call_graph".to_string());
}
if estimated_size > max_prompt_chars {
tracing::warn!(
section = "graph_context",
priority_tier = 2,
chars = graph_context.len(),
"Dropping section: prompt budget exceeded (graph_context tier)"
);
let dropped_chars = graph_context.len();
graph_context.clear();
estimated_size -= dropped_chars;
budget_drops.push("graph_context".to_string());
}
if estimated_size > max_prompt_chars {
tracing::warn!(
section = "ast_context",
chars = ast_context.len(),
"Dropping section: prompt budget exceeded"
);
let dropped_chars = ast_context.len();
ast_context.clear();
estimated_size -= dropped_chars;
budget_drops.push("ast_context".to_string());
}
drop_dep_enrichments_by_size(pr, &mut estimated_size, max_prompt_chars, budget_drops);
drop_patches_by_size(
&mut pr.files,
&mut estimated_size,
max_prompt_chars,
budget_drops,
);
drop_full_content_by_size(
&mut pr.files,
&mut estimated_size,
max_prompt_chars,
budget_drops,
);
}
fn drop_dep_enrichments_by_size(
pr: &mut PrDetails,
estimated_size: &mut usize,
max_prompt_chars: usize,
budget_drops: &mut Vec<String>,
) {
if *estimated_size <= max_prompt_chars {
return;
}
let dropped_chars: usize = pr
.dep_enrichments
.iter()
.map(|d| d.body.len() + d.package_name.len() + d.github_url.len())
.sum();
if dropped_chars > 0 {
tracing::warn!(
section = "dep_enrichments",
chars = dropped_chars,
"Dropping section: prompt budget exceeded"
);
pr.dep_enrichments.clear();
*estimated_size -= dropped_chars;
budget_drops.push("dep_enrichments".to_string());
}
}
fn drop_patches_by_size(
files: &mut [crate::ai::types::PrFile],
estimated_size: &mut usize,
max_prompt_chars: usize,
budget_drops: &mut Vec<String>,
) {
if *estimated_size <= max_prompt_chars {
return;
}
let mut file_sizes: Vec<(usize, usize)> = files
.iter()
.enumerate()
.map(|(idx, f)| (idx, f.patch.as_ref().map_or(0, String::len)))
.collect();
file_sizes.sort_by_key(|x| std::cmp::Reverse(x.1));
for (file_idx, patch_size) in file_sizes {
if *estimated_size <= max_prompt_chars {
break;
}
if patch_size > 0 {
tracing::warn!(
file = %files[file_idx].filename,
patch_chars = patch_size,
"Dropping patch: prompt budget exceeded"
);
let filename = files[file_idx].filename.clone();
files[file_idx].patch = None;
*estimated_size -= patch_size;
budget_drops.push(format!("file_content:{filename}"));
}
}
}
fn drop_full_content_by_size(
files: &mut [crate::ai::types::PrFile],
estimated_size: &mut usize,
max_prompt_chars: usize,
budget_drops: &mut Vec<String>,
) {
if *estimated_size <= max_prompt_chars {
return;
}
let mut full_content_sizes: Vec<(usize, usize)> = files
.iter()
.enumerate()
.map(|(idx, f)| (idx, f.full_content.as_ref().map_or(0, String::len)))
.collect();
full_content_sizes.sort_by_key(|x| std::cmp::Reverse(x.1));
for (file_idx, content_size) in full_content_sizes {
if *estimated_size <= max_prompt_chars {
break;
}
if content_size > 0 {
tracing::warn!(
file = %files[file_idx].filename,
content_chars = content_size,
"Dropping full_content: prompt budget exceeded"
);
let filename = files[file_idx].filename.clone();
files[file_idx].full_content = None;
*estimated_size -= content_size;
budget_drops.push(format!("file_content:{filename}"));
}
}
}
#[must_use]
pub(crate) fn estimate_pr_size(
pr: &PrDetails,
ast_context: &str,
call_graph: &str,
graph_context: &str,
) -> usize {
let mut size = 0;
size += pr.title.len() + pr.body.len() + pr.head_branch.len() + pr.base_branch.len();
for file in &pr.files {
size += file.filename.len() + file.status.len();
if let Some(patch) = &file.patch {
size += patch.len();
}
if let Some(content) = &file.full_content {
size += content.len();
}
}
for dep in &pr.dep_enrichments {
size += dep.package_name.len() + dep.body.len() + dep.github_url.len();
}
size += ast_context.len();
size += call_graph.len();
size += graph_context.len();
size += PROMPT_OVERHEAD_CHARS;
size
}
#[allow(clippy::unused_async)]
#[cfg(feature = "ast-context")]
async fn build_ctx_ast(
repo_path: Option<&str>,
files: &[crate::ai::types::PrFile],
) -> crate::ast_context::AstContextOutput {
let Some(path) = repo_path else {
return crate::ast_context::AstContextOutput::new(String::new());
};
crate::ast_context::build_ast_context(path, files).await
}
#[allow(clippy::unused_async)]
#[cfg(not(feature = "ast-context"))]
async fn build_ctx_ast(repo_path: Option<&str>, files: &[crate::ai::types::PrFile]) -> String {
let _ = (repo_path, files);
String::new()
}
#[allow(clippy::unused_async)]
async fn build_ctx_call_graph(
repo_path: Option<&str>,
files: &[crate::ai::types::PrFile],
deep: bool,
) -> String {
if !deep {
return String::new();
}
let Some(path) = repo_path else {
return String::new();
};
#[cfg(feature = "ast-context")]
{
return crate::ast_context::build_call_graph_context(path, files).await;
}
#[cfg(not(feature = "ast-context"))]
{
let _ = (path, files);
String::new()
}
}
#[allow(clippy::unused_async)]
#[cfg(feature = "ast-context")]
async fn build_ctx_graph(
graph_config: &crate::config::GraphConfig,
repo_path: Option<&str>,
pr: &PrDetails,
ast_output: &crate::ast_context::AstContextOutput,
) -> (String, bool) {
#[cfg(feature = "graph")]
{
if !graph_config.enabled {
return (String::new(), false);
}
let Some(_repo_path_str) = repo_path else {
return (String::new(), false);
};
let sha = pr.head_sha.clone();
let owner_str = pr.owner.clone();
let repo_str = pr.repo.clone();
let graph_config_owned = graph_config.clone();
let graph_owned = ast_output.graph.clone();
let function_names: Vec<String> = derive_modified_symbols(&pr.files);
let spawn_result = tokio::task::spawn_blocking(move || {
let (mut graph, cache_hit) = crate::graph::cache::load_or_build(
&owner_str,
&repo_str,
&sha,
graph_owned,
&graph_config_owned,
);
let fn_refs: Vec<&str> = function_names.iter().map(String::as_str).collect();
let modified_nodes = crate::graph::query::find_modified_nodes(&mut graph, &fn_refs);
let subgraph = crate::graph::query::blast_radius(
&graph,
&modified_nodes,
graph_config_owned.max_nodes,
graph_config_owned.max_depth,
);
(
crate::graph::query::render_subgraph_text(&subgraph),
cache_hit,
)
})
.await;
match spawn_result {
Ok(v) => v,
Err(e) => {
tracing::warn!("graph cache spawn_blocking panicked: {e}");
(String::new(), false)
}
}
}
#[cfg(not(feature = "graph"))]
{
let _ = (graph_config, repo_path, pr, ast_output);
(String::new(), false)
}
}
#[allow(clippy::unused_async)]
#[cfg(not(feature = "ast-context"))]
async fn build_ctx_graph(
graph_config: &crate::config::GraphConfig,
repo_path: Option<&str>,
pr: &PrDetails,
_ast_text: &str,
) -> (String, bool) {
let _ = (graph_config, repo_path, pr);
(String::new(), false)
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
fn derive_modified_symbols(files: &[crate::ai::types::PrFile]) -> Vec<String> {
let mut symbols: Vec<String> = Vec::new();
for file in files {
let Some(patch) = &file.patch else {
continue;
};
for line in patch.lines() {
if line.starts_with("@@") || line.starts_with('-') || line.starts_with("+++") {
continue;
}
if let Some(caps) = SYMBOL_RE.captures(line) {
let keyword = caps.get(2).map_or("", |m| m.as_str());
let name = caps.get(3).map_or("", |m| m.as_str()).to_string();
let sym = if keyword == "impl" {
let trimmed = line.strip_prefix('+').unwrap_or("").trim();
let after_impl = trimmed.strip_prefix("impl ").unwrap_or("");
if let Some(for_pos) = after_impl.find(" for ") {
after_impl[for_pos + 5..]
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
} else {
name
}
} else {
name
};
if !sym.is_empty() && !symbols.contains(&sym) {
symbols.push(sym);
}
}
}
}
symbols
}
#[cfg(not(target_arch = "wasm32"))]
fn infer_repo_path_from_cwd(pr_owner: &str, pr_repo: &str) -> Option<String> {
let git_root = get_git_root()?;
let origin_url = get_git_origin_url()?;
let Some((origin_owner, origin_repo)) = parse_origin_owner_repo(&origin_url) else {
tracing::debug!(
"infer_repo_path_from_cwd: parse_origin_owner_repo failed for {}",
origin_url
);
return None;
};
let pr_owner_lower = pr_owner.to_lowercase();
let pr_repo_lower = pr_repo.to_lowercase();
if origin_owner == pr_owner_lower && origin_repo == pr_repo_lower {
tracing::debug!(
"infer_repo_path_from_cwd: matched origin {}/{} with PR {}/{}",
origin_owner,
origin_repo,
pr_owner_lower,
pr_repo_lower
);
Some(git_root)
} else {
tracing::debug!(
"infer_repo_path_from_cwd: origin {}/{} does not match PR {}/{}",
origin_owner,
origin_repo,
pr_owner_lower,
pr_repo_lower
);
None
}
}
#[cfg(not(target_arch = "wasm32"))]
fn get_git_root() -> Option<String> {
use std::process::Command;
Command::new("git")
.arg("rev-parse")
.arg("--show-toplevel")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
fn get_git_origin_url() -> Option<String> {
use std::process::Command;
Command::new("git")
.arg("remote")
.arg("get-url")
.arg("origin")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
}
fn parse_origin_owner_repo(url: &str) -> Option<(String, String)> {
use crate::utils::parse_git_remote_url;
let Ok(parsed) = parse_git_remote_url(url) else {
return None;
};
let parts: Vec<&str> = parsed.split('/').collect();
if parts.len() != 2 {
return None;
}
let owner = parts[0].to_lowercase();
let repo = parts[1].to_lowercase();
Some((owner, repo))
}
#[must_use]
pub(crate) fn truncate_at_line_boundary(content: &str, max_chars: usize) -> String {
if content.chars().count() <= max_chars {
return content.to_string();
}
let cutoff_byte = content
.char_indices()
.nth(max_chars)
.map_or(content.len(), |(i, _)| i);
let truncated = &content[..cutoff_byte];
if let Some(newline_pos) = truncated.rfind('\n') {
content[..=newline_pos].to_string()
} else {
content[..cutoff_byte].to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::types::{DepReleaseNote, PrFile};
fn make_pr_with_content(patch_chars: usize, full_content_chars: usize) -> PrDetails {
PrDetails {
number: 1,
title: "test".to_string(),
body: String::new(),
owner: "owner".to_string(),
repo: "repo".to_string(),
url: "https://github.com/owner/repo/pull/1".to_string(),
head_branch: "feat".to_string(),
base_branch: "main".to_string(),
head_sha: String::new(),
review_comments: vec![],
files: vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some("x".repeat(patch_chars)),
patch_truncated: false,
full_content: if full_content_chars > 0 {
Some("y".repeat(full_content_chars))
} else {
None
},
additions: 1,
deletions: 0,
}],
dep_enrichments: vec![],
instructions: None,
labels: vec![],
}
}
fn make_dep(package_name: &str) -> DepReleaseNote {
DepReleaseNote {
package_name: package_name.to_string(),
old_version: "1.0.0".to_string(),
new_version: "1.0.1".to_string(),
registry: "crates.io".to_string(),
github_url: format!("https://github.com/owner/{package_name}"),
body: "release notes".to_string(),
fetch_note: String::new(),
}
}
#[test]
fn test_apply_budget_drops_order() {
let mut pr = make_pr_with_content(500, 500);
let mut ast_context = "a".repeat(300);
let mut call_graph = "b".repeat(300);
let max_prompt_chars = 600;
let mut drops = Vec::new();
let mut graph_context = String::new();
apply_budget_drops(
&mut pr,
&mut ast_context,
&mut call_graph,
&mut graph_context,
false,
max_prompt_chars,
&mut drops,
);
assert!(
call_graph.is_empty(),
"call_graph should be dropped first when over budget"
);
}
#[test]
fn test_apply_budget_drops_dep_enrichments_before_patches() {
let mut pr = make_pr_with_content(200, 0);
pr.dep_enrichments.push(make_dep("serde"));
pr.dep_enrichments[0].body = "d".repeat(400);
let mut ast_context = String::new();
let mut call_graph = String::new();
let max_prompt_chars = 1400;
let mut drops = Vec::new();
let mut graph_context = String::new();
apply_budget_drops(
&mut pr,
&mut ast_context,
&mut call_graph,
&mut graph_context,
false,
max_prompt_chars,
&mut drops,
);
assert!(
pr.dep_enrichments.is_empty(),
"dep_enrichments should be dropped before file patches"
);
assert!(
pr.files[0].patch.is_some(),
"file patch should be retained when dep drop brought size within budget"
);
}
#[test]
fn test_verbose_summary_all_fields() {
let mut pr = make_pr_with_content(10, 0);
pr.dep_enrichments = vec![make_dep("tokio"), make_dep("serde")];
let ctx = ReviewContext {
pr,
ast_context: "fn foo() {}".to_string(),
call_graph: "foo -> bar".to_string(),
inferred_repo_path: Some(std::path::PathBuf::from("/tmp/repo")),
cwd_inferred: true,
..Default::default()
};
let summary = ctx.verbose_summary();
assert!(
summary.contains("/tmp/repo"),
"summary should contain the repo path"
);
assert!(
summary.contains("(inferred)"),
"summary should mark CWD-inferred path"
);
assert!(
summary.contains("tokio"),
"summary should list dep package names"
);
assert!(
summary.contains("serde"),
"summary should list dep package names"
);
assert!(
summary.contains("AST:"),
"summary should include AST char count"
);
assert!(
summary.contains("call graph:"),
"summary should include call graph char count"
);
}
#[test]
fn test_verbose_summary_empty_context() {
let pr = make_pr_with_content(0, 0);
let ctx = ReviewContext {
pr,
..Default::default()
};
let summary = ctx.verbose_summary();
assert!(
summary.is_empty(),
"summary should be empty when no enrichments are present"
);
}
#[test]
fn test_verbose_summary_truncation_section_present_and_absent() {
let pr = make_pr_with_content(0, 0);
let ctx_with = ReviewContext {
pr: pr.clone(),
max_chars_per_file: 4_000,
files_truncated: 3,
truncated_chars_dropped: 900,
..Default::default()
};
let summary = ctx_with.verbose_summary();
assert!(
summary.contains("Files truncated: 3 (900 chars dropped)"),
"verbose_summary must include truncation line when files_truncated > 0"
);
let ctx_without = ReviewContext {
pr,
max_chars_per_file: 4_000,
..Default::default()
};
let summary_clean = ctx_without.verbose_summary();
assert!(
!summary_clean.contains("Files truncated"),
"verbose_summary must omit truncation line when files_truncated == 0"
);
}
#[test]
fn test_should_enable_call_graph_budget_boundary() {
let config = ReviewConfig {
min_budget_for_call_graph: 20_000,
..ReviewConfig::default()
};
assert!(
!should_enable_call_graph(false, 20_000, &config),
"should_enable_call_graph must be false when budget_remaining equals min_budget_for_call_graph"
);
}
#[test]
fn test_should_enable_call_graph_budget_below_threshold() {
let config = ReviewConfig {
min_budget_for_call_graph: 20_000,
..ReviewConfig::default()
};
assert!(
!should_enable_call_graph(false, 10_000, &config),
"should_enable_call_graph must be false when budget_remaining < min_budget_for_call_graph and deep=false"
);
}
#[test]
fn test_should_enable_call_graph_deep_overrides_budget() {
let config = ReviewConfig {
min_budget_for_call_graph: 20_000,
..ReviewConfig::default()
};
assert!(
should_enable_call_graph(true, 0, &config),
"should_enable_call_graph must be true when deep=true regardless of budget_remaining"
);
}
#[test]
fn test_truncate_at_line_boundary_happy_path() {
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
let result = truncate_at_line_boundary(content, 20);
assert_eq!(result, "line 1\nline 2\n");
assert!(
result.chars().count() <= 20,
"truncated result must not exceed max_chars"
);
assert!(
result.ends_with('\n'),
"truncation should end at newline boundary when one exists"
);
}
#[test]
fn test_truncate_at_line_boundary_fallback_no_newline() {
let content = "abcdefghijklmnopqrstuvwxyz";
let result = truncate_at_line_boundary(content, 10);
assert_eq!(result, "abcdefghij");
assert_eq!(result.chars().count(), 10);
}
#[test]
fn test_truncate_at_line_boundary_under_limit() {
let content = "short";
let result = truncate_at_line_boundary(content, 100);
assert_eq!(result, "short");
assert_eq!(result.chars().count(), 5);
}
#[test]
fn test_truncate_at_line_boundary_multi_byte_utf8() {
let content: String = (0..30).map(|_| "\u{1F600}").collect(); let result = truncate_at_line_boundary(&content, 25);
assert_eq!(result.chars().count(), 25);
assert!(result.chars().all(|c| c == '\u{1F600}'));
}
#[test]
fn test_estimate_pr_size_includes_call_graph() {
let pr = make_pr_with_content(0, 0);
let ast_context = "";
let call_graph = "fn foo() -> bar\nfn baz() -> qux";
let size = estimate_pr_size(&pr, ast_context, call_graph, "");
let without_call_graph = estimate_pr_size(&pr, ast_context, "", "");
assert_eq!(size - without_call_graph, call_graph.len());
assert!(size >= PROMPT_OVERHEAD_CHARS);
}
#[test]
fn test_build_review_context_estimated_size_pre_budget() {
let pr = make_pr_with_content(50, 100);
let ast_context = "fn foo() {}";
let call_graph = "caller -> callee\nother -> thing";
let size = estimate_pr_size(&pr, ast_context, call_graph, "");
assert!(
size >= call_graph.len() + PROMPT_OVERHEAD_CHARS,
"estimated size {} should be >= call_graph.len() {} + overhead {}",
size,
call_graph.len(),
PROMPT_OVERHEAD_CHARS
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_patch_none_yields_empty() {
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: None,
patch_truncated: false,
full_content: None,
additions: 0,
deletions: 0,
}];
let symbols = derive_modified_symbols(&files);
assert!(symbols.is_empty(), "patch=None should yield empty symbols");
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_extracts_from_hunk_lines() {
let patch = "\
@@ -1,5 +1,10 @@
fn existing_fn() {}
+fn new_fn() -> Result<()> {
+ Ok(())
+}
+pub struct NewStruct {
+ field: i32,
+}
+pub enum NewEnum {
+ VariantA,
+}
+impl NewStruct {
+ fn method(&self) {}
+}
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 5,
deletions: 0,
}];
let mut symbols = derive_modified_symbols(&files);
symbols.sort();
assert!(
symbols.contains(&"new_fn".to_string()),
"should extract fn name"
);
assert!(
symbols.contains(&"NewStruct".to_string()),
"should extract struct name"
);
assert!(
symbols.contains(&"NewEnum".to_string()),
"should extract enum name"
);
assert!(
symbols.contains(&"NewStruct".to_string()),
"impl target should be extracted"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_patch_truncated_yields_partial() {
let patch = "\
@@ -1,5 +1,8 @@
fn existing_fn() {}
+fn visible_fn() {}
+pub struct VisibleStruct {
+ field: i32,
+}
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: true,
full_content: None,
additions: 3,
deletions: 0,
}];
let symbols = derive_modified_symbols(&files);
assert!(
symbols.contains(&"visible_fn".to_string()),
"should extract fn from truncated patch"
);
assert!(
symbols.contains(&"VisibleStruct".to_string()),
"should extract struct from truncated patch"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_renamed_file_treated_as_modified() {
let patch = "\
@@ -1,1 +1,1 @@
-fn old_name() {}
+fn renamed_fn() {}
";
let files = vec![PrFile {
filename: "src/renamed.rs".to_string(),
status: "renamed".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 1,
deletions: 1,
}];
let symbols = derive_modified_symbols(&files);
assert!(
symbols.contains(&"renamed_fn".to_string()),
"should extract fn from renamed file"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_async_fn() {
let patch = "\
@@ -1,3 +1,6 @@
fn sync_fn() {}
+async fn fetch_data() -> Result<()> {
+ Ok(())
+}
+pub async fn handle_request() -> String {
+ String::new()
+}
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 2,
deletions: 0,
}];
let mut symbols = derive_modified_symbols(&files);
symbols.sort();
assert!(
symbols.contains(&"fetch_data".to_string()),
"should extract async fn name"
);
assert!(
symbols.contains(&"handle_request".to_string()),
"should extract pub async fn name"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_pub_visibility() {
let patch = "\
@@ -1,3 +1,5 @@
fn existing() {}
+pub(crate) fn internal_fn() -> i32 { 42 }
+pub(super) fn super_fn() -> bool { true }
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 2,
deletions: 0,
}];
let mut symbols = derive_modified_symbols(&files);
symbols.sort();
assert!(
symbols.contains(&"internal_fn".to_string()),
"should extract fn with pub(crate) visibility"
);
assert!(
symbols.contains(&"super_fn".to_string()),
"should extract fn with pub(super) visibility"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_generic_fn() {
let patch = "\
@@ -1,3 +1,5 @@
fn existing() {}
+fn generic_fn<T: Debug>(x: T) -> String { format!(\"{:?}\", x) }
+fn multi_bound_fn<T: Clone + Debug, U: Display>(a: T, b: U) {}
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 2,
deletions: 0,
}];
let mut symbols = derive_modified_symbols(&files);
symbols.sort();
assert!(
symbols.contains(&"generic_fn".to_string()),
"should extract generic fn name without generic params"
);
assert!(
symbols.contains(&"multi_bound_fn".to_string()),
"should extract multi-bound generic fn name"
);
}
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[test]
fn test_modified_symbols_tuple_unit_structs() {
let patch = "\
@@ -1,3 +1,6 @@
fn existing() {}
+struct Point(i32, i32);
+struct Unit;
+pub struct Named {
+ field: i32,
+}
";
let files = vec![PrFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
patch: Some(patch.to_string()),
patch_truncated: false,
full_content: None,
additions: 3,
deletions: 0,
}];
let mut symbols = derive_modified_symbols(&files);
symbols.sort();
assert!(
symbols.contains(&"Point".to_string()),
"should extract tuple struct name"
);
assert!(
symbols.contains(&"Unit".to_string()),
"should extract unit struct name"
);
assert!(
symbols.contains(&"Named".to_string()),
"should extract named struct name"
);
}
}