use std::collections::{HashMap, HashSet};
use std::path::Path;
use chrono::Utc;
use crate::git::depfiles::{diff_dependencies, is_dependency_file};
use crate::git::diff::ChangeType;
use crate::git::generated::GeneratedFileDetector;
use crate::git::reader::RepoReader;
use crate::pagination::{CURSOR_VERSION, PaginationCursor, PaginationInfo, encode_cursor};
use crate::tools::extension_from_path;
use crate::tools::size;
use crate::tools::types::{
FunctionChange, FunctionChangeType, ImportChange, ManifestFileEntry, ManifestMetadata,
ManifestOptions, ManifestResponse, ManifestSummary, ToolError, detect_language,
};
use crate::treesitter::{Function, analyzer_for_extension};
pub fn diff_functions(base_fns: &[Function], head_fns: &[Function]) -> Vec<FunctionChange> {
let base_map: HashMap<&str, &Function> =
base_fns.iter().map(|f| (f.name.as_str(), f)).collect();
let head_map: HashMap<&str, &Function> =
head_fns.iter().map(|f| (f.name.as_str(), f)).collect();
let mut changes = Vec::new();
let mut unmatched_added: Vec<&Function> = Vec::new();
let mut unmatched_deleted: Vec<&Function> = Vec::new();
for head_fn in head_map.values() {
match base_map.get(head_fn.name.as_str()) {
None => unmatched_added.push(head_fn),
Some(base_fn) => {
if base_fn.signature != head_fn.signature {
changes.push(FunctionChange::from_function(
head_fn,
FunctionChangeType::SignatureChanged,
None,
));
} else if base_fn.body_hash != head_fn.body_hash {
changes.push(FunctionChange::from_function(
head_fn,
FunctionChangeType::Modified,
None,
));
}
}
}
}
for base_fn in base_map.values() {
if !head_map.contains_key(base_fn.name.as_str()) {
unmatched_deleted.push(base_fn);
}
}
let mut deleted_by_hash: HashMap<&str, Vec<&Function>> = HashMap::new();
for del_fn in &unmatched_deleted {
deleted_by_hash
.entry(del_fn.body_hash.as_str())
.or_default()
.push(del_fn);
}
let mut matched_deleted: HashSet<&str> = HashSet::new();
for added_fn in &unmatched_added {
if let Some(candidates) = deleted_by_hash.get_mut(added_fn.body_hash.as_str())
&& let Some(del_fn) = candidates.pop()
{
changes.push(FunctionChange::from_function(
added_fn,
FunctionChangeType::Renamed,
Some(del_fn.name.clone()),
));
matched_deleted.insert(del_fn.name.as_str());
continue;
}
changes.push(FunctionChange::from_function(
added_fn,
FunctionChangeType::Added,
None,
));
}
for del_fn in &unmatched_deleted {
if !matched_deleted.contains(del_fn.name.as_str()) {
changes.push(FunctionChange::from_function(
del_fn,
FunctionChangeType::Deleted,
None,
));
}
}
changes.sort_by(|a, b| a.name.cmp(&b.name));
changes
}
pub fn diff_imports(base_imports: &[String], head_imports: &[String]) -> ImportChange {
let base_set: HashSet<&str> = base_imports.iter().map(|s| s.as_str()).collect();
let head_set: HashSet<&str> = head_imports.iter().map(|s| s.as_str()).collect();
let mut added: Vec<String> = head_set
.difference(&base_set)
.map(|s| s.to_string())
.collect();
let mut removed: Vec<String> = base_set
.difference(&head_set)
.map(|s| s.to_string())
.collect();
added.sort();
removed.sort();
ImportChange { added, removed }
}
fn matches_glob_pattern(path: &str, pattern: &str) -> bool {
glob::Pattern::new(pattern)
.map(|p| {
p.matches_with(
path,
glob::MatchOptions {
require_literal_separator: false,
require_literal_leading_dot: false,
case_sensitive: true,
},
)
})
.unwrap_or(false)
}
pub fn build_manifest(
repo_path: &Path,
base_ref: &str,
head_ref: &str,
options: &ManifestOptions,
offset: usize,
page_size: usize,
) -> Result<ManifestResponse, ToolError> {
let reader = RepoReader::open(repo_path)?;
let base_commit = reader.resolve_commit(base_ref)?;
let head_commit = reader.resolve_commit(head_ref)?;
let diff_result = reader.diff_commits(base_ref, head_ref)?;
let mut files_to_process = diff_result.files;
if !options.include_patterns.is_empty() {
files_to_process.retain(|f| {
options
.include_patterns
.iter()
.any(|p| matches_glob_pattern(&f.path, p))
});
}
if !options.exclude_patterns.is_empty() {
files_to_process.retain(|f| {
!options
.exclude_patterns
.iter()
.any(|p| matches_glob_pattern(&f.path, p))
});
}
let total_files = files_to_process.len();
let is_paginating = total_files > page_size;
let mut all_languages_set = std::collections::HashSet::new();
let mut summary_files_added = 0usize;
let mut summary_files_modified = 0usize;
let mut summary_files_deleted = 0usize;
let mut summary_files_renamed = 0usize;
let mut summary_lines_added = 0usize;
let mut summary_lines_removed = 0usize;
for file_change in &files_to_process {
let language = detect_language(&file_change.path);
if language != "unknown" {
all_languages_set.insert(language.to_string());
}
match file_change.change_type {
ChangeType::Added => summary_files_added += 1,
ChangeType::Modified => summary_files_modified += 1,
ChangeType::Deleted => summary_files_deleted += 1,
ChangeType::Renamed | ChangeType::Copied => summary_files_renamed += 1,
}
summary_lines_added += file_change.lines_added;
summary_lines_removed += file_change.lines_removed;
}
let mut all_languages_affected: Vec<String> = all_languages_set.into_iter().collect();
all_languages_affected.sort();
let mut dependency_changes = Vec::new();
for file_change in &files_to_process {
if is_dependency_file(&file_change.path) {
let _dep_span = tracing::info_span!("manifest.diff_dependencies").entered();
let base_content = match file_change.change_type {
ChangeType::Added => String::new(),
_ => reader
.read_file_at_ref(base_ref, &file_change.path)
.unwrap_or_default(),
};
let head_content = match file_change.change_type {
ChangeType::Deleted => String::new(),
_ => reader
.read_file_at_ref(head_ref, &file_change.path)
.unwrap_or_default(),
};
if let Some(dep_diff) =
diff_dependencies(&file_change.path, &base_content, &head_content)
{
dependency_changes.push(dep_diff);
}
}
}
let page_end = (offset + page_size).min(total_files);
#[rustfmt::skip]
let page_files = if offset < total_files { &files_to_process[offset..page_end] } else { &[] };
let mut manifest_files = Vec::new();
let mut total_functions_changed: Option<usize> = None;
for file_change in page_files {
let language = detect_language(&file_change.path);
let ext = extension_from_path(&file_change.path);
let is_generated = {
let _span = tracing::info_span!("manifest.detect_generated").entered();
GeneratedFileDetector::is_generated(&file_change.path, None)
};
let _file_span =
tracing::info_span!("manifest.analyze_file", file.language = language).entered();
let (functions_changed, imports_changed) = if let Some(analyzer) = options
.include_function_analysis
.then(|| analyzer_for_extension(ext))
.flatten()
{
let base_content = match file_change.change_type {
ChangeType::Added => None,
_ => reader.read_file_at_ref(base_ref, &file_change.path).ok(),
};
let head_content = match file_change.change_type {
ChangeType::Deleted => None,
_ => reader.read_file_at_ref(head_ref, &file_change.path).ok(),
};
let (base_fns, head_fns, base_imports, head_imports) = {
let _parse_span =
tracing::info_span!("treesitter.parse", language = language).entered();
let base_fns = base_content
.as_ref()
.and_then(|c| analyzer.extract_functions(c.as_bytes()).ok())
.unwrap_or_default();
let head_fns = head_content
.as_ref()
.and_then(|c| analyzer.extract_functions(c.as_bytes()).ok())
.unwrap_or_default();
let base_imports = base_content
.as_ref()
.and_then(|c| analyzer.extract_imports(c.as_bytes()).ok())
.unwrap_or_default();
let head_imports = head_content
.as_ref()
.and_then(|c| analyzer.extract_imports(c.as_bytes()).ok())
.unwrap_or_default();
(base_fns, head_fns, base_imports, head_imports)
};
let fn_changes = {
let _span = tracing::info_span!("treesitter.extract_functions").entered();
diff_functions(&base_fns, &head_fns)
};
if !is_paginating {
let count = fn_changes.len();
*total_functions_changed.get_or_insert(0) += count;
}
let import_change = {
let _span = tracing::info_span!("treesitter.extract_imports").entered();
diff_imports(&base_imports, &head_imports)
};
(Some(fn_changes), Some(import_change))
} else {
(None, None)
};
manifest_files.push(ManifestFileEntry {
path: file_change.path.clone(),
old_path: file_change.old_path.clone(),
change_type: file_change.change_type,
change_scope: file_change.change_scope,
language: language.to_string(),
is_binary: file_change.is_binary,
is_generated,
lines_added: file_change.lines_added,
lines_removed: file_change.lines_removed,
size_before: file_change.size_before,
size_after: file_change.size_after,
functions_changed,
imports_changed,
});
}
let next_cursor = if page_end < total_files {
Some(encode_cursor(&PaginationCursor {
version: CURSOR_VERSION,
offset: page_end,
base_sha: base_commit.sha.clone(),
head_sha: head_commit.sha.clone(),
}))
} else {
None
};
let summary = ManifestSummary {
total_files_changed: total_files,
files_added: summary_files_added,
files_modified: summary_files_modified,
files_deleted: summary_files_deleted,
files_renamed: summary_files_renamed,
total_lines_added: summary_lines_added,
total_lines_removed: summary_lines_removed,
total_functions_changed: if is_paginating {
None
} else {
total_functions_changed
},
languages_affected: all_languages_affected,
};
let mut response = ManifestResponse {
metadata: ManifestMetadata {
repo_path: repo_path.display().to_string(),
base_ref: base_ref.to_string(),
head_ref: head_ref.to_string(),
base_sha: base_commit.sha,
head_sha: head_commit.sha,
generated_at: Utc::now(),
version: env!("CARGO_PKG_VERSION").to_string(),
token_estimate: 0,
function_analysis_truncated: vec![],
budget_tokens: None,
},
summary,
files: manifest_files,
dependency_changes,
pagination: PaginationInfo {
total_items: total_files,
page_start: offset,
page_size,
next_cursor,
},
};
let trimmed = if options.include_function_analysis {
match options.max_response_tokens {
Some(budget) if budget > 0 => enforce_token_budget(&mut response, budget),
_ => vec![],
}
} else {
vec![]
};
response.metadata.function_analysis_truncated = trimmed;
let actual_page_files = response.files.len();
if actual_page_files < page_end.saturating_sub(offset) {
let actual_end = offset + actual_page_files;
response.pagination.page_size = actual_page_files;
if actual_end < total_files {
response.pagination.next_cursor = Some(encode_cursor(&PaginationCursor {
version: CURSOR_VERSION,
offset: actual_end,
base_sha: response.metadata.base_sha.clone(),
head_sha: response.metadata.head_sha.clone(),
}));
}
}
response.metadata.token_estimate = size::estimate_response_tokens(&response);
Ok(response)
}
pub fn build_worktree_manifest(
repo_path: &Path,
base_ref: &str,
options: &ManifestOptions,
offset: usize,
page_size: usize,
) -> Result<ManifestResponse, ToolError> {
let reader = RepoReader::open(repo_path)?;
let base_commit = reader.resolve_commit(base_ref)?;
let diff_result = reader.diff_worktree()?;
let mut files_to_process = diff_result.files;
if !options.include_patterns.is_empty() {
files_to_process.retain(|f| {
options
.include_patterns
.iter()
.any(|p| matches_glob_pattern(&f.path, p))
});
}
if !options.exclude_patterns.is_empty() {
files_to_process.retain(|f| {
!options
.exclude_patterns
.iter()
.any(|p| matches_glob_pattern(&f.path, p))
});
}
let total_files = files_to_process.len();
let is_paginating = total_files > page_size;
let mut all_languages_set = std::collections::HashSet::new();
let mut summary_files_added = 0usize;
let mut summary_files_modified = 0usize;
let mut summary_files_deleted = 0usize;
let mut summary_files_renamed = 0usize;
let mut summary_lines_added = 0usize;
let mut summary_lines_removed = 0usize;
for file_change in &files_to_process {
let language = detect_language(&file_change.path);
if language != "unknown" {
all_languages_set.insert(language.to_string());
}
match file_change.change_type {
ChangeType::Added => summary_files_added += 1,
ChangeType::Modified => summary_files_modified += 1,
ChangeType::Deleted => summary_files_deleted += 1,
ChangeType::Renamed | ChangeType::Copied => summary_files_renamed += 1,
}
summary_lines_added += file_change.lines_added;
summary_lines_removed += file_change.lines_removed;
}
let mut all_languages_affected: Vec<String> = all_languages_set.into_iter().collect();
all_languages_affected.sort();
let page_end = (offset + page_size).min(total_files);
#[rustfmt::skip]
let page_files = if offset < total_files { &files_to_process[offset..page_end] } else { &[] };
let mut manifest_files = Vec::new();
let mut total_functions_changed: Option<usize> = None;
for file_change in page_files {
let language = detect_language(&file_change.path);
let ext = extension_from_path(&file_change.path);
let is_generated = {
let _span = tracing::info_span!("manifest.detect_generated").entered();
GeneratedFileDetector::is_generated(&file_change.path, None)
};
let _file_span =
tracing::info_span!("manifest.analyze_file", file.language = language).entered();
let (functions_changed, imports_changed) = if let Some(analyzer) = options
.include_function_analysis
.then(|| analyzer_for_extension(ext))
.flatten()
{
let base_content = match file_change.change_type {
ChangeType::Added => None,
_ => reader.read_file_at_ref(base_ref, &file_change.path).ok(),
};
let head_content = match file_change.change_type {
ChangeType::Deleted => None,
_ => match &file_change.staged_blob_id {
Some(blob_id) => reader.read_blob(blob_id).ok(),
None => read_worktree_file(repo_path, &file_change.path),
},
};
let (base_fns, head_fns, base_imports, head_imports) = {
let _parse_span =
tracing::info_span!("treesitter.parse", language = language).entered();
let base_fns = base_content
.as_ref()
.and_then(|c| analyzer.extract_functions(c.as_bytes()).ok())
.unwrap_or_default();
let head_fns = head_content
.as_ref()
.and_then(|c| analyzer.extract_functions(c.as_bytes()).ok())
.unwrap_or_default();
let base_imports = base_content
.as_ref()
.and_then(|c| analyzer.extract_imports(c.as_bytes()).ok())
.unwrap_or_default();
let head_imports = head_content
.as_ref()
.and_then(|c| analyzer.extract_imports(c.as_bytes()).ok())
.unwrap_or_default();
(base_fns, head_fns, base_imports, head_imports)
};
let fn_changes = {
let _span = tracing::info_span!("treesitter.extract_functions").entered();
diff_functions(&base_fns, &head_fns)
};
if !is_paginating {
let count = fn_changes.len();
*total_functions_changed.get_or_insert(0) += count;
}
let import_change = {
let _span = tracing::info_span!("treesitter.extract_imports").entered();
diff_imports(&base_imports, &head_imports)
};
(Some(fn_changes), Some(import_change))
} else {
(None, None)
};
manifest_files.push(ManifestFileEntry {
path: file_change.path.clone(),
old_path: file_change.old_path.clone(),
change_type: file_change.change_type,
change_scope: file_change.change_scope,
language: language.to_string(),
is_binary: file_change.is_binary,
is_generated,
lines_added: file_change.lines_added,
lines_removed: file_change.lines_removed,
size_before: file_change.size_before,
size_after: file_change.size_after,
functions_changed,
imports_changed,
});
}
let next_cursor = if page_end < total_files {
Some(encode_cursor(&PaginationCursor {
version: CURSOR_VERSION,
offset: page_end,
base_sha: base_commit.sha.clone(),
head_sha: "WORKTREE".to_string(),
}))
} else {
None
};
let summary = ManifestSummary {
total_files_changed: total_files,
files_added: summary_files_added,
files_modified: summary_files_modified,
files_deleted: summary_files_deleted,
files_renamed: summary_files_renamed,
total_lines_added: summary_lines_added,
total_lines_removed: summary_lines_removed,
total_functions_changed: if is_paginating {
None
} else {
total_functions_changed
},
languages_affected: all_languages_affected,
};
let mut response = ManifestResponse {
metadata: ManifestMetadata {
repo_path: repo_path.display().to_string(),
base_ref: base_ref.to_string(),
head_ref: "WORKTREE".to_string(),
base_sha: base_commit.sha,
head_sha: "WORKTREE".to_string(),
generated_at: Utc::now(),
version: env!("CARGO_PKG_VERSION").to_string(),
token_estimate: 0,
function_analysis_truncated: vec![],
budget_tokens: None,
},
summary,
files: manifest_files,
dependency_changes: vec![],
pagination: PaginationInfo {
total_items: total_files,
page_start: offset,
page_size,
next_cursor,
},
};
let trimmed = if options.include_function_analysis {
match options.max_response_tokens {
Some(budget) if budget > 0 => enforce_token_budget(&mut response, budget),
_ => vec![],
}
} else {
vec![]
};
response.metadata.function_analysis_truncated = trimmed;
let actual_page_files = response.files.len();
if actual_page_files < page_end.saturating_sub(offset) {
let actual_end = offset + actual_page_files;
response.pagination.page_size = actual_page_files;
if actual_end < total_files {
response.pagination.next_cursor = Some(encode_cursor(&PaginationCursor {
version: CURSOR_VERSION,
offset: actual_end,
base_sha: response.metadata.base_sha.clone(),
head_sha: "WORKTREE".to_string(),
}));
}
}
response.metadata.token_estimate = size::estimate_response_tokens(&response);
Ok(response)
}
pub fn enforce_token_budget(response: &mut ManifestResponse, budget: usize) -> Vec<String> {
let files = std::mem::take(&mut response.files);
let skeleton_cost = size::estimate_response_tokens(response);
response.files = files;
let safety_margin = (budget / 20).max(16);
let file_budget = budget
.saturating_sub(skeleton_cost)
.saturating_sub(safety_margin);
struct FileCosts {
full: usize,
tier1: usize, bare: usize, has_analysis: bool,
}
let costs: Vec<FileCosts> = response
.files
.iter()
.map(|f| {
let full = size::estimate_response_tokens(f);
let tier1 = if f.imports_changed.is_some() {
let mut clone = f.clone();
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
} else {
full
};
let bare = {
let mut clone = f.clone();
clone.functions_changed = None;
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
};
FileCosts {
full,
tier1,
bare,
has_analysis: f.functions_changed.is_some(),
}
})
.collect();
let total_full: usize = costs.iter().map(|c| c.full).sum();
let total_tier1: usize = costs.iter().map(|c| c.tier1).sum();
if total_full <= file_budget {
return vec![];
}
if total_tier1 <= file_budget {
let mut trimmed = Vec::new();
for file in &mut response.files {
if file.imports_changed.is_some() {
file.imports_changed = None;
trimmed.push(file.path.clone());
}
}
return trimmed;
}
let mut remaining = file_budget;
let mut decisions: Vec<TierChoice> = Vec::with_capacity(costs.len());
for c in &costs {
if c.has_analysis && c.tier1 <= remaining {
remaining -= c.tier1;
decisions.push(TierChoice::Tier1);
} else if c.bare <= remaining {
remaining -= c.bare;
decisions.push(TierChoice::Bare);
} else {
break;
}
}
let files_to_keep = decisions.len();
response.files.truncate(files_to_keep);
let mut trimmed = Vec::new();
for (i, file) in response.files.iter_mut().enumerate() {
match decisions[i] {
TierChoice::Tier1 => {
file.imports_changed = None;
trimmed.push(file.path.clone());
}
TierChoice::Bare => {
file.functions_changed = None;
file.imports_changed = None;
}
}
}
trimmed
}
#[derive(Clone, Copy)]
enum TierChoice {
Tier1,
Bare,
}
fn read_worktree_file(repo_path: &Path, file_path: &str) -> Option<String> {
let full_path = repo_path.join(file_path);
std::fs::read_to_string(&full_path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::diff::ChangeScope;
#[test]
fn it_detects_added_function() {
let base = vec![];
let head = vec![Function {
name: "foo".into(),
signature: "fn foo()".into(),
start_line: 1,
end_line: 3,
body_hash: "aaa".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "foo");
assert_eq!(changes[0].change_type, FunctionChangeType::Added);
assert!(
changes[0].old_name.is_none(),
"Added should not have old_name"
);
}
#[test]
fn it_detects_deleted_function() {
let base = vec![Function {
name: "bar".into(),
signature: "fn bar()".into(),
start_line: 1,
end_line: 3,
body_hash: "bbb".into(),
}];
let head = vec![];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "bar");
assert_eq!(changes[0].change_type, FunctionChangeType::Deleted);
assert!(
changes[0].old_name.is_none(),
"Deleted should not have old_name"
);
}
#[test]
fn it_detects_signature_changed_function() {
let base = vec![Function {
name: "baz".into(),
signature: "fn baz()".into(),
start_line: 1,
end_line: 3,
body_hash: "ccc".into(),
}];
let head = vec![Function {
name: "baz".into(),
signature: "fn baz(x: i32)".into(),
start_line: 1,
end_line: 5,
body_hash: "ddd".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, FunctionChangeType::SignatureChanged);
assert!(
changes[0].old_name.is_none(),
"SignatureChanged should not have old_name"
);
}
#[test]
fn it_detects_modified_function_by_body_hash_change() {
let base = vec![Function {
name: "qux".into(),
signature: "fn qux()".into(),
start_line: 1,
end_line: 3,
body_hash: "eee".into(),
}];
let head = vec![Function {
name: "qux".into(),
signature: "fn qux()".into(),
start_line: 1,
end_line: 10,
body_hash: "fff".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, FunctionChangeType::Modified);
assert!(changes[0].old_name.is_none());
}
#[test]
fn line_range_change_alone_does_not_trigger_modified() {
let base = vec![Function {
name: "qux".into(),
signature: "fn qux()".into(),
start_line: 1,
end_line: 3,
body_hash: "same_hash".into(),
}];
let head = vec![Function {
name: "qux".into(),
signature: "fn qux()".into(),
start_line: 50,
end_line: 100,
body_hash: "same_hash".into(),
}];
let changes = diff_functions(&base, &head);
assert!(
changes.is_empty(),
"line range change with same body_hash should NOT produce Modified"
);
}
#[test]
fn it_returns_empty_for_identical_functions() {
let fns = vec![Function {
name: "same".into(),
signature: "fn same()".into(),
start_line: 1,
end_line: 3,
body_hash: "ggg".into(),
}];
let changes = diff_functions(&fns, &fns);
assert!(changes.is_empty());
}
#[test]
fn moved_but_unchanged_function_produces_no_change() {
let base = vec![Function {
name: "foo".into(),
signature: "fn foo()".into(),
start_line: 1,
end_line: 5,
body_hash: "same_hash".into(),
}];
let head = vec![Function {
name: "foo".into(),
signature: "fn foo()".into(),
start_line: 10,
end_line: 14,
body_hash: "same_hash".into(),
}];
let changes = diff_functions(&base, &head);
assert!(
changes.is_empty(),
"moved-but-unchanged should produce no change"
);
}
#[test]
fn body_only_change_detected_as_modified() {
let base = vec![Function {
name: "compute".into(),
signature: "fn compute(x: i32) -> i32".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_v1".into(),
}];
let head = vec![Function {
name: "compute".into(),
signature: "fn compute(x: i32) -> i32".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_v2".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "compute");
assert_eq!(changes[0].change_type, FunctionChangeType::Modified);
assert!(
changes[0].old_name.is_none(),
"Modified should not have old_name"
);
}
#[test]
fn rename_detected_by_body_hash() {
let base = vec![Function {
name: "old_name".into(),
signature: "fn old_name(x: i32)".into(),
start_line: 1,
end_line: 3,
body_hash: "shared_hash".into(),
}];
let head = vec![Function {
name: "new_name".into(),
signature: "fn new_name(x: i32)".into(),
start_line: 1,
end_line: 3,
body_hash: "shared_hash".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "new_name");
assert_eq!(changes[0].change_type, FunctionChangeType::Renamed);
assert_eq!(changes[0].old_name.as_deref(), Some("old_name"));
}
#[test]
fn rename_plus_body_change_shows_deleted_and_added() {
let base = vec![Function {
name: "old_name".into(),
signature: "fn old_name()".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_a".into(),
}];
let head = vec![Function {
name: "new_name".into(),
signature: "fn new_name()".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_b".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 2);
let deleted = changes
.iter()
.find(|c| c.change_type == FunctionChangeType::Deleted)
.unwrap();
assert_eq!(deleted.name, "old_name");
let added = changes
.iter()
.find(|c| c.change_type == FunctionChangeType::Added)
.unwrap();
assert_eq!(added.name, "new_name");
}
#[test]
fn swapped_functions_produce_no_changes() {
let base = vec![
Function {
name: "foo".into(),
signature: "fn foo()".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_foo".into(),
},
Function {
name: "bar".into(),
signature: "fn bar()".into(),
start_line: 5,
end_line: 7,
body_hash: "hash_bar".into(),
},
];
let head = vec![
Function {
name: "bar".into(),
signature: "fn bar()".into(),
start_line: 1,
end_line: 3,
body_hash: "hash_bar".into(),
},
Function {
name: "foo".into(),
signature: "fn foo()".into(),
start_line: 5,
end_line: 7,
body_hash: "hash_foo".into(),
},
];
let changes = diff_functions(&base, &head);
assert!(
changes.is_empty(),
"swapped functions should produce no changes"
);
}
#[test]
fn multiple_renames_detected() {
let base = vec![
Function {
name: "a".into(),
signature: "fn a()".into(),
start_line: 1,
end_line: 2,
body_hash: "hash_x".into(),
},
Function {
name: "b".into(),
signature: "fn b()".into(),
start_line: 3,
end_line: 4,
body_hash: "hash_y".into(),
},
];
let head = vec![
Function {
name: "c".into(),
signature: "fn c()".into(),
start_line: 1,
end_line: 2,
body_hash: "hash_x".into(),
},
Function {
name: "d".into(),
signature: "fn d()".into(),
start_line: 3,
end_line: 4,
body_hash: "hash_y".into(),
},
];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 2);
assert!(
changes
.iter()
.all(|c| c.change_type == FunctionChangeType::Renamed)
);
let c_change = changes.iter().find(|c| c.name == "c").unwrap();
assert_eq!(c_change.old_name.as_deref(), Some("a"));
let d_change = changes.iter().find(|c| c.name == "d").unwrap();
assert_eq!(d_change.old_name.as_deref(), Some("b"));
}
#[test]
fn non_rename_changes_have_null_old_name() {
let base = vec![Function {
name: "deleted_fn".into(),
signature: "fn deleted_fn()".into(),
start_line: 1,
end_line: 3,
body_hash: "xxx".into(),
}];
let head = vec![Function {
name: "added_fn".into(),
signature: "fn added_fn()".into(),
start_line: 1,
end_line: 3,
body_hash: "yyy".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 2);
for c in &changes {
assert!(
c.old_name.is_none(),
"non-rename changes should have None old_name"
);
}
}
#[test]
fn duplicate_body_hash_produces_correct_rename_and_delete_counts() {
let base = vec![
Function {
name: "a".into(),
signature: "fn a()".into(),
start_line: 1,
end_line: 2,
body_hash: "stub_hash".into(),
},
Function {
name: "b".into(),
signature: "fn b()".into(),
start_line: 3,
end_line: 4,
body_hash: "stub_hash".into(),
},
];
let head = vec![Function {
name: "c".into(),
signature: "fn c()".into(),
start_line: 1,
end_line: 2,
body_hash: "stub_hash".into(),
}];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 2);
let renamed_count = changes
.iter()
.filter(|c| c.change_type == FunctionChangeType::Renamed)
.count();
let deleted_count = changes
.iter()
.filter(|c| c.change_type == FunctionChangeType::Deleted)
.count();
assert_eq!(renamed_count, 1, "exactly one rename");
assert_eq!(deleted_count, 1, "exactly one delete");
let renamed = changes
.iter()
.find(|c| c.change_type == FunctionChangeType::Renamed)
.unwrap();
assert_eq!(renamed.name, "c");
assert!(
renamed.old_name.as_deref() == Some("a") || renamed.old_name.as_deref() == Some("b"),
"old_name should be one of the deleted functions, got {:?}",
renamed.old_name
);
}
#[test]
fn more_added_than_deleted_with_same_hash() {
let base = vec![Function {
name: "old".into(),
signature: "fn old()".into(),
start_line: 1,
end_line: 2,
body_hash: "stub_hash".into(),
}];
let head = vec![
Function {
name: "new_a".into(),
signature: "fn new_a()".into(),
start_line: 1,
end_line: 2,
body_hash: "stub_hash".into(),
},
Function {
name: "new_b".into(),
signature: "fn new_b()".into(),
start_line: 3,
end_line: 4,
body_hash: "stub_hash".into(),
},
];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 2);
let renamed_count = changes
.iter()
.filter(|c| c.change_type == FunctionChangeType::Renamed)
.count();
let added_count = changes
.iter()
.filter(|c| c.change_type == FunctionChangeType::Added)
.count();
assert_eq!(renamed_count, 1, "exactly one rename");
assert_eq!(added_count, 1, "exactly one added");
}
#[test]
fn it_diffs_imports_correctly() {
let base = vec!["fmt".to_string(), "os".to_string()];
let head = vec!["fmt".to_string(), "io".to_string()];
let change = diff_imports(&base, &head);
assert_eq!(change.added, vec!["io"]);
assert_eq!(change.removed, vec!["os"]);
}
#[test]
fn it_returns_empty_import_diff_for_identical_imports() {
let imports = vec!["fmt".to_string()];
let change = diff_imports(&imports, &imports);
assert!(change.added.is_empty());
assert!(change.removed.is_empty());
}
#[test]
fn it_handles_mixed_function_changes() {
let base = vec![
Function {
name: "kept".into(),
signature: "fn kept()".into(),
start_line: 1,
end_line: 3,
body_hash: "hhh".into(),
},
Function {
name: "removed".into(),
signature: "fn removed()".into(),
start_line: 5,
end_line: 7,
body_hash: "iii".into(),
},
Function {
name: "changed_sig".into(),
signature: "fn changed_sig()".into(),
start_line: 9,
end_line: 11,
body_hash: "jjj".into(),
},
];
let head = vec![
Function {
name: "kept".into(),
signature: "fn kept()".into(),
start_line: 1,
end_line: 3,
body_hash: "hhh".into(),
},
Function {
name: "added".into(),
signature: "fn added()".into(),
start_line: 5,
end_line: 7,
body_hash: "kkk".into(),
},
Function {
name: "changed_sig".into(),
signature: "fn changed_sig(x: i32)".into(),
start_line: 9,
end_line: 13,
body_hash: "lll".into(),
},
];
let changes = diff_functions(&base, &head);
assert_eq!(changes.len(), 3);
let added = changes.iter().find(|c| c.name == "added").unwrap();
assert_eq!(added.change_type, FunctionChangeType::Added);
let removed = changes.iter().find(|c| c.name == "removed").unwrap();
assert_eq!(removed.change_type, FunctionChangeType::Deleted);
let sig = changes.iter().find(|c| c.name == "changed_sig").unwrap();
assert_eq!(sig.change_type, FunctionChangeType::SignatureChanged);
}
fn create_repo_with_go_file() -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("main.go"),
"package main\n\nimport \"fmt\"\n\nfunc hello() {\n\tfmt.Println(\"hello\")\n}\n",
)
.unwrap();
std::fs::write(path.join("README.md"), "# Test\n").unwrap();
Command::new("git")
.args(["add", "main.go", "README.md"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("main.go"),
"package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc hello() {\n\tfmt.Println(\"hello\")\n}\n\nfunc goodbye() {\n\tfmt.Println(\"bye\")\n\tos.Exit(0)\n}\n",
)
.unwrap();
std::fs::write(path.join("README.md"), "# Test Project\n\nUpdated.\n").unwrap();
Command::new("git")
.args(["add", "main.go", "README.md"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add goodbye function"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn it_reports_a_positive_token_estimate_for_a_non_trivial_manifest() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
manifest.metadata.token_estimate > 0,
"expected a positive token_estimate on a non-trivial manifest response, got {}",
manifest.metadata.token_estimate,
);
}
#[test]
fn it_builds_manifest_with_function_analysis() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert_eq!(manifest.summary.total_files_changed, 2);
assert!(manifest.pagination.next_cursor.is_none());
let go_file = manifest.files.iter().find(|f| f.path == "main.go").unwrap();
assert_eq!(go_file.language, "go");
assert!(!go_file.is_generated);
let fns = go_file.functions_changed.as_ref().unwrap();
let added_fn = fns.iter().find(|f| f.name == "goodbye").unwrap();
assert_eq!(added_fn.change_type, FunctionChangeType::Added);
let imports = go_file.imports_changed.as_ref().unwrap();
assert!(imports.added.iter().any(|i| i.contains("os")));
let readme = manifest
.files
.iter()
.find(|f| f.path == "README.md")
.unwrap();
assert!(readme.functions_changed.is_none());
}
#[test]
fn it_applies_exclude_patterns() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec!["*.md".to_string()],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert_eq!(manifest.summary.total_files_changed, 1);
assert_eq!(manifest.files[0].path, "main.go");
}
#[test]
fn it_applies_include_patterns() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec!["*.go".to_string()],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert_eq!(manifest.summary.total_files_changed, 1);
assert_eq!(manifest.files[0].path, "main.go");
}
#[test]
fn it_sets_functions_changed_to_none_without_analysis() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
for file in &manifest.files {
assert!(file.functions_changed.is_none());
assert!(file.imports_changed.is_none());
}
assert!(manifest.summary.total_functions_changed.is_none());
}
fn create_repo_with_reordered_functions() -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n\nfn farewell(name: &str) -> String {\n format!(\"Goodbye, {}!\", name)\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn farewell(name: &str) -> String {\n format!(\"Goodbye, {}!\", name)\n}\n\nfn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "swap order"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn reordered_functions_produce_zero_function_changes() {
let (_dir, path) = create_repo_with_reordered_functions();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest.files.iter().find(|f| f.path == "lib.rs").unwrap();
let fns = rs_file.functions_changed.as_ref().unwrap();
assert!(
fns.is_empty(),
"reordered functions should produce no changes, got: {fns:?}"
);
}
fn create_repo_with_body_change() -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn compute(x: i32) -> i32 {\n x + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn compute(x: i32) -> i32 {\n x * 2 + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "change body"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn body_only_change_detected_in_real_repo() {
let (_dir, path) = create_repo_with_body_change();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest.files.iter().find(|f| f.path == "lib.rs").unwrap();
let fns = rs_file.functions_changed.as_ref().unwrap();
assert_eq!(fns.len(), 1, "exactly one function change expected");
assert_eq!(fns[0].name, "compute");
assert_eq!(fns[0].change_type, FunctionChangeType::Modified);
assert!(fns[0].old_name.is_none());
}
fn create_repo_with_rename() -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn old_name(x: i32) -> i32 {\n x + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn new_name(x: i32) -> i32 {\n x + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "rename function"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn rename_detected_in_real_repo() {
let (_dir, path) = create_repo_with_rename();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest.files.iter().find(|f| f.path == "lib.rs").unwrap();
let fns = rs_file.functions_changed.as_ref().unwrap();
assert_eq!(fns.len(), 1, "expected single rename, got: {fns:?}");
assert_eq!(fns[0].name, "new_name");
assert_eq!(fns[0].change_type, FunctionChangeType::Renamed);
assert_eq!(fns[0].old_name.as_deref(), Some("old_name"));
}
fn create_repo_with_rename_and_modify() -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn old_name(x: i32) -> i32 {\n x + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.rs"),
"fn new_name(x: i32) -> i32 {\n x * 2 + 1\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "rename and modify"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn rename_plus_modify_shows_deleted_and_added_in_real_repo() {
let (_dir, path) = create_repo_with_rename_and_modify();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest.files.iter().find(|f| f.path == "lib.rs").unwrap();
let fns = rs_file.functions_changed.as_ref().unwrap();
assert_eq!(fns.len(), 2, "expected deleted + added, got: {fns:?}");
let deleted = fns
.iter()
.find(|f| f.change_type == FunctionChangeType::Deleted);
assert!(deleted.is_some(), "expected a Deleted change");
assert_eq!(deleted.unwrap().name, "old_name");
let added = fns
.iter()
.find(|f| f.change_type == FunctionChangeType::Added);
assert!(added.is_some(), "expected an Added change");
assert_eq!(added.unwrap().name, "new_name");
}
#[test]
fn it_sorts_function_changes_by_name() {
let base = vec![];
let head = vec![
Function {
name: "zebra".into(),
signature: "fn zebra()".into(),
start_line: 1,
end_line: 2,
body_hash: "mmm".into(),
},
Function {
name: "alpha".into(),
signature: "fn alpha()".into(),
start_line: 3,
end_line: 4,
body_hash: "nnn".into(),
},
];
let changes = diff_functions(&base, &head);
assert_eq!(changes[0].name, "alpha");
assert_eq!(changes[1].name, "zebra");
}
#[test]
fn it_matches_glob_across_directory_separators() {
assert!(matches_glob_pattern("src/lib.rs", "*.rs"));
}
#[test]
fn it_matches_glob_at_root_level() {
assert!(matches_glob_pattern("main.rs", "*.rs"));
}
#[test]
fn it_matches_exact_filename_pattern() {
assert!(matches_glob_pattern("Cargo.toml", "Cargo.toml"));
}
#[test]
fn it_returns_false_for_invalid_glob() {
assert!(!matches_glob_pattern("main.rs", "[invalid"));
}
#[test]
fn it_is_case_sensitive() {
assert!(!matches_glob_pattern("main.rs", "*.RS"));
}
#[test]
fn it_returns_empty_diff_when_both_import_lists_are_empty() {
let base: Vec<String> = vec![];
let head: Vec<String> = vec![];
let change = diff_imports(&base, &head);
assert!(change.added.is_empty());
assert!(change.removed.is_empty());
}
#[test]
fn it_reports_all_added_when_base_imports_are_empty() {
let base: Vec<String> = vec![];
let head = vec!["fmt".to_string(), "os".to_string()];
let change = diff_imports(&base, &head);
assert_eq!(change.added, vec!["fmt", "os"]);
assert!(change.removed.is_empty());
}
#[test]
fn it_reports_all_removed_when_head_imports_are_empty() {
let base = vec!["fmt".to_string(), "os".to_string()];
let head: Vec<String> = vec![];
let change = diff_imports(&base, &head);
assert!(change.added.is_empty());
assert_eq!(change.removed, vec!["fmt", "os"]);
}
#[test]
fn it_reads_staged_content_from_index_not_disk_for_function_analysis() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("lib.py"), "def original():\n return 1\n").unwrap();
Command::new("git")
.args(["add", "lib.py"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.py"),
"def original():\n return 1\n\ndef staged_fn():\n return 2\n",
)
.unwrap();
Command::new("git")
.args(["add", "lib.py"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("lib.py"),
"def original():\n return 1\n\ndef disk_fn():\n return 3\n",
)
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 200).unwrap();
let staged_entry = manifest
.files
.iter()
.find(|f| f.path == "lib.py" && f.change_scope == crate::git::diff::ChangeScope::Staged)
.expect("should have a staged entry for lib.py");
let fns = staged_entry
.functions_changed
.as_ref()
.expect("should have function analysis");
assert!(
fns.iter().any(|f| f.name == "staged_fn"),
"staged entry should show 'staged_fn' from index, not 'disk_fn' from disk. Got: {:?}",
fns.iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert!(
!fns.iter().any(|f| f.name == "disk_fn"),
"staged entry should NOT show 'disk_fn' from disk"
);
}
#[test]
fn it_builds_worktree_manifest_with_staged_file() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("existing.txt"), "hello\n").unwrap();
Command::new("git")
.args(["add", "existing.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("new.py"), "def foo(): pass\n").unwrap();
Command::new("git")
.args(["add", "new.py"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 200).unwrap();
assert!(manifest.summary.total_files_changed > 0);
let new_file = manifest.files.iter().find(|f| f.path == "new.py").unwrap();
assert_eq!(new_file.change_type, ChangeType::Added);
}
fn create_repo_with_n_files(n: usize) -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
for i in 0..n {
std::fs::write(path.join(format!("file{i}.txt")), format!("content {i}\n")).unwrap();
}
let mut add_args = vec!["add".to_string()];
for i in 0..n {
add_args.push(format!("file{i}.txt"));
}
Command::new("git")
.args(&add_args)
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add files"])
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn it_returns_no_cursor_when_files_fit_in_page() {
let (_dir, path) = create_repo_with_n_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
manifest.pagination.next_cursor.is_none(),
"should have no cursor when all files fit in page"
);
assert_eq!(manifest.pagination.total_items, 5);
assert_eq!(manifest.files.len(), 5);
}
#[test]
fn it_paginates_when_files_exceed_page_size() {
let (_dir, path) = create_repo_with_n_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 3).unwrap();
assert_eq!(
manifest.files.len(),
3,
"should return only page_size files"
);
assert_eq!(manifest.pagination.total_items, 5);
assert_eq!(manifest.pagination.page_start, 0);
assert_eq!(manifest.pagination.page_size, 3);
assert!(
manifest.pagination.next_cursor.is_some(),
"should have cursor when more files remain"
);
}
#[test]
fn it_counts_known_language_in_languages_affected() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
manifest
.summary
.languages_affected
.contains(&"go".to_string()),
"go should be in languages_affected, got: {:?}",
manifest.summary.languages_affected
);
assert!(
!manifest
.summary
.languages_affected
.contains(&"unknown".to_string()),
"unknown should not be in languages_affected"
);
}
#[test]
fn it_skips_base_content_for_added_files_in_function_analysis() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("new.rs"),
"fn brand_new() {\n println!(\"new\");\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "new.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add new rust file"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest.files.iter().find(|f| f.path == "new.rs").unwrap();
assert_eq!(rs_file.change_type, ChangeType::Added);
let fns = rs_file.functions_changed.as_ref().unwrap();
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].name, "brand_new");
assert_eq!(fns[0].change_type, FunctionChangeType::Added);
}
#[test]
fn it_skips_head_content_for_deleted_files_in_function_analysis() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("doomed.rs"),
"fn doomed_fn() {\n println!(\"bye\");\n}\n",
)
.unwrap();
Command::new("git")
.args(["add", "doomed.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial with function"])
.current_dir(&path)
.output()
.unwrap();
std::fs::remove_file(path.join("doomed.rs")).unwrap();
Command::new("git")
.args(["add", "doomed.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "delete rust file"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
let rs_file = manifest
.files
.iter()
.find(|f| f.path == "doomed.rs")
.unwrap();
assert_eq!(rs_file.change_type, ChangeType::Deleted);
let fns = rs_file.functions_changed.as_ref().unwrap();
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].name, "doomed_fn");
assert_eq!(fns[0].change_type, FunctionChangeType::Deleted);
}
#[test]
fn it_accumulates_total_functions_changed_across_files() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("a.rs"), "fn alpha() {}\n").unwrap();
std::fs::write(path.join("b.rs"), "fn beta() {}\n").unwrap();
Command::new("git")
.args(["add", "a.rs", "b.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("a.rs"), "fn alpha() {}\nfn alpha2() {}\n").unwrap();
std::fs::write(path.join("b.rs"), "fn beta() {}\nfn beta2() {}\n").unwrap();
Command::new("git")
.args(["add", "a.rs", "b.rs"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add functions"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert_eq!(
manifest.summary.total_functions_changed,
Some(2),
"total_functions_changed should be sum, not product"
);
}
#[test]
fn it_uses_empty_base_content_for_added_dep_file() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\n",
)
.unwrap();
Command::new("git")
.args(["add", "Cargo.toml"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add cargo.toml"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
!manifest.dependency_changes.is_empty(),
"should detect dependency changes for added Cargo.toml"
);
let dep = &manifest.dependency_changes[0];
assert!(
!dep.added.is_empty(),
"should show added dependencies for new Cargo.toml"
);
}
#[test]
fn it_uses_empty_head_content_for_deleted_dep_file() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\n",
)
.unwrap();
Command::new("git")
.args(["add", "Cargo.toml"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial with cargo"])
.current_dir(&path)
.output()
.unwrap();
std::fs::remove_file(path.join("Cargo.toml")).unwrap();
Command::new("git")
.args(["add", "Cargo.toml"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "delete cargo.toml"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
!manifest.dependency_changes.is_empty(),
"should detect dependency changes for deleted Cargo.toml"
);
let dep = &manifest.dependency_changes[0];
assert!(
!dep.removed.is_empty(),
"should show removed dependencies for deleted Cargo.toml"
);
}
#[test]
fn it_counts_summary_change_types_correctly() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("modify.txt"), "original\n").unwrap();
std::fs::write(path.join("delete.txt"), "to be deleted\n").unwrap();
Command::new("git")
.args(["add", "modify.txt", "delete.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("modify.txt"), "changed\n").unwrap();
std::fs::remove_file(path.join("delete.txt")).unwrap();
std::fs::write(path.join("added.txt"), "new file\n").unwrap();
Command::new("git")
.args(["add", "modify.txt", "delete.txt", "added.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "mixed changes"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert_eq!(manifest.summary.total_files_changed, 3);
assert_eq!(
manifest.summary.files_added, 1,
"should have exactly 1 added file"
);
assert_eq!(
manifest.summary.files_modified, 1,
"should have exactly 1 modified file"
);
assert_eq!(
manifest.summary.files_deleted, 1,
"should have exactly 1 deleted file"
);
assert_eq!(
manifest.summary.files_renamed, 0,
"should have exactly 0 renamed files"
);
}
#[test]
fn it_worktree_excludes_patterns_correctly() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("keep.txt"), "keep\n").unwrap();
std::fs::write(path.join("drop.log"), "drop\n").unwrap();
Command::new("git")
.args(["add", "keep.txt", "drop.log"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("keep.txt"), "keep changed\n").unwrap();
std::fs::write(path.join("drop.log"), "drop changed\n").unwrap();
Command::new("git")
.args(["add", "keep.txt", "drop.log"])
.current_dir(&path)
.output()
.unwrap();
let options_exclude = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec!["*.log".to_string()],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options_exclude, 0, 200).unwrap();
assert!(
manifest.files.iter().any(|f| f.path == "keep.txt"),
"keep.txt should be included"
);
assert!(
!manifest.files.iter().any(|f| f.path == "drop.log"),
"drop.log should be excluded by pattern"
);
let options_include = ManifestOptions {
include_patterns: vec!["*.txt".to_string()],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options_include, 0, 200).unwrap();
assert!(
manifest.files.iter().any(|f| f.path == "keep.txt"),
"keep.txt should be included by pattern"
);
assert!(
!manifest.files.iter().any(|f| f.path == "drop.log"),
"drop.log should not match *.txt include pattern"
);
}
fn create_worktree_repo_with_n_staged_files(
n: usize,
) -> (tempfile::TempDir, std::path::PathBuf) {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
let mut add_args = vec!["add".to_string()];
for i in 0..n {
let filename = format!("wt_file{i}.txt");
std::fs::write(path.join(&filename), format!("content {i}\n")).unwrap();
add_args.push(filename);
}
Command::new("git")
.args(&add_args)
.current_dir(&path)
.output()
.unwrap();
(dir, path)
}
#[test]
fn it_worktree_returns_no_cursor_when_files_fit_in_page() {
let (_dir, path) = create_worktree_repo_with_n_staged_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 200).unwrap();
assert!(
manifest.pagination.next_cursor.is_none(),
"worktree should have no cursor when all files fit in page"
);
assert_eq!(manifest.pagination.total_items, 5);
assert_eq!(manifest.files.len(), 5);
}
#[test]
fn it_worktree_paginates_when_files_exceed_page_size() {
let (_dir, path) = create_worktree_repo_with_n_staged_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 3).unwrap();
assert_eq!(
manifest.files.len(),
3,
"should return only page_size files"
);
assert_eq!(manifest.pagination.total_items, 5);
assert_eq!(manifest.pagination.page_start, 0);
assert_eq!(manifest.pagination.page_size, 3);
assert!(
manifest.pagination.next_cursor.is_some(),
"worktree should have cursor when more files remain"
);
}
#[test]
fn it_worktree_counts_known_language() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("hello.py"), "print('hi')\n").unwrap();
Command::new("git")
.args(["add", "hello.py"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 200).unwrap();
assert!(
manifest
.summary
.languages_affected
.contains(&"python".to_string()),
"worktree: python should be in languages_affected"
);
assert!(
!manifest
.summary
.languages_affected
.contains(&"unknown".to_string()),
"worktree: unknown should NOT be in languages_affected"
);
}
#[test]
fn it_worktree_accumulates_total_functions_changed() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("a.py"), "def func_a():\n pass\n").unwrap();
std::fs::write(path.join("b.py"), "def func_b():\n pass\n").unwrap();
Command::new("git")
.args(["add", "a.py", "b.py"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("a.py"),
"def func_a():\n pass\n\ndef func_a2():\n pass\n",
)
.unwrap();
std::fs::write(
path.join("b.py"),
"def func_b():\n pass\n\ndef func_b2():\n pass\n",
)
.unwrap();
Command::new("git")
.args(["add", "a.py", "b.py"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 200).unwrap();
assert_eq!(
manifest.summary.total_functions_changed,
Some(2),
"worktree total_functions_changed should be sum (2), not product"
);
}
#[test]
fn it_read_worktree_file_returns_file_content() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
let content = "def real_content():\n return 42\n";
std::fs::write(path.join("test.py"), content).unwrap();
let result = read_worktree_file(&path, "test.py");
assert!(result.is_some(), "should return Some for existing file");
let file_content = result.unwrap();
assert_eq!(
file_content, content,
"should return actual file content, not empty or dummy"
);
assert!(
file_content.contains("real_content"),
"should contain actual function name"
);
assert_ne!(file_content, "", "should not be empty");
assert_ne!(file_content, "xyzzy", "should not be dummy value");
}
#[test]
fn it_read_worktree_file_returns_none_for_missing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
let result = read_worktree_file(&path, "nonexistent.py");
assert!(result.is_none(), "should return None for missing file");
}
#[test]
fn it_summary_counts_reflect_all_files_on_every_page() {
let (_dir, path) = create_repo_with_n_files(10);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let page1 = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 3).unwrap();
assert_eq!(page1.summary.total_files_changed, 10);
assert_eq!(page1.files.len(), 3);
let page2 = build_manifest(&path, "HEAD~1", "HEAD", &options, 3, 3).unwrap();
assert_eq!(page2.summary.total_files_changed, 10);
assert_eq!(page2.files.len(), 3);
}
#[test]
fn it_second_page_returns_different_files_than_first() {
let (_dir, path) = create_repo_with_n_files(6);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let page1 = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 3).unwrap();
let page2 = build_manifest(&path, "HEAD~1", "HEAD", &options, 3, 3).unwrap();
let page1_paths: Vec<&str> = page1.files.iter().map(|f| f.path.as_str()).collect();
let page2_paths: Vec<&str> = page2.files.iter().map(|f| f.path.as_str()).collect();
for p in &page2_paths {
assert!(
!page1_paths.contains(p),
"page2 file {:?} should not appear in page1",
p
);
}
}
#[test]
fn it_last_page_has_no_cursor() {
let (_dir, path) = create_repo_with_n_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 3, 3).unwrap();
assert_eq!(
manifest.files.len(),
2,
"last page should have remaining files"
);
assert!(
manifest.pagination.next_cursor.is_none(),
"last page should have no cursor"
);
}
#[test]
fn it_sets_total_functions_changed_to_none_when_paginating() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert!(
manifest.summary.total_functions_changed.is_none(),
"total_functions_changed should be None when paginating"
);
}
#[test]
fn it_total_functions_changed_present_when_not_paginating() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 200).unwrap();
assert!(
manifest.summary.total_functions_changed.is_some(),
"total_functions_changed should be present when not paginating"
);
}
#[test]
fn it_treesitter_only_runs_on_current_page() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("a.go"), "package main\n\nfunc funcA() {}\n").unwrap();
std::fs::write(path.join("b.go"), "package main\n\nfunc funcB() {}\n").unwrap();
Command::new("git")
.args(["add", "a.go", "b.go"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add go files"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec!["*.go".to_string()],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let page1 = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert_eq!(page1.files.len(), 1);
assert!(page1.files[0].functions_changed.is_some());
let page2 = build_manifest(&path, "HEAD~1", "HEAD", &options, 1, 1).unwrap();
assert_eq!(page2.files.len(), 1);
assert!(page2.files[0].functions_changed.is_some());
assert_ne!(page1.files[0].path, page2.files[0].path);
}
#[test]
fn it_worktree_summary_reflects_all_files_when_paginated() {
let (_dir, path) = create_worktree_repo_with_n_staged_files(8);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let page1 = build_worktree_manifest(&path, "HEAD", &options, 0, 3).unwrap();
assert_eq!(page1.summary.total_files_changed, 8);
assert_eq!(page1.files.len(), 3);
assert!(page1.pagination.next_cursor.is_some());
let page2 = build_worktree_manifest(&path, "HEAD", &options, 3, 3).unwrap();
assert_eq!(page2.summary.total_files_changed, 8);
assert_eq!(page2.files.len(), 3);
}
#[test]
fn it_worktree_last_page_has_no_cursor() {
let (_dir, path) = create_worktree_repo_with_n_staged_files(5);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 3, 3).unwrap();
assert_eq!(manifest.files.len(), 2);
assert!(
manifest.pagination.next_cursor.is_none(),
"worktree last page should have no cursor"
);
}
#[test]
fn it_cursor_encodes_correct_next_offset() {
let (_dir, path) = create_repo_with_n_files(10);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 3).unwrap();
let cursor_str = manifest.pagination.next_cursor.as_ref().unwrap();
let cursor = crate::pagination::decode_cursor(cursor_str).unwrap();
assert_eq!(cursor.offset, 3, "next cursor offset should be page_end");
assert_eq!(cursor.version, 1);
assert_eq!(cursor.base_sha, manifest.metadata.base_sha);
assert_eq!(cursor.head_sha, manifest.metadata.head_sha);
}
#[test]
fn it_dependency_changes_always_complete_regardless_of_pagination() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "init.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\n",
)
.unwrap();
std::fs::write(path.join("a.txt"), "a\n").unwrap();
std::fs::write(path.join("b.txt"), "b\n").unwrap();
Command::new("git")
.args(["add", "Cargo.toml", "a.txt", "b.txt"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add files"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert!(
!manifest.dependency_changes.is_empty(),
"dependency changes should always be complete regardless of pagination"
);
}
#[test]
fn it_returns_empty_files_when_offset_beyond_total() {
let (_dir, path) = create_repo_with_n_files(3);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 999, 100).unwrap();
assert!(manifest.files.is_empty());
assert!(manifest.pagination.next_cursor.is_none());
assert!(manifest.summary.total_files_changed > 0);
}
#[test]
fn it_is_paginating_only_when_total_exceeds_page_size() {
let (_dir, path) = create_repo_with_go_file();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 2).unwrap();
assert_eq!(manifest.files.len(), 2);
assert!(manifest.pagination.next_cursor.is_none());
assert!(
manifest.summary.total_functions_changed.is_some(),
"should have function count when not paginating"
);
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert_eq!(manifest.files.len(), 1);
assert!(manifest.pagination.next_cursor.is_some());
assert!(
manifest.summary.total_functions_changed.is_none(),
"should suppress function count when paginating"
);
}
#[test]
fn it_counts_summary_added_modified_deleted_correctly_with_pagination() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("modify_me.rs"), "fn old() {}\n").unwrap();
std::fs::write(path.join("delete_me.rs"), "fn gone() {}\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("added.rs"), "fn new() {}\n").unwrap();
std::fs::write(path.join("modify_me.rs"), "fn modified() {}\n").unwrap();
std::fs::remove_file(path.join("delete_me.rs")).unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "changes"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert_eq!(manifest.summary.files_added, 1);
assert_eq!(manifest.summary.files_modified, 1);
assert_eq!(manifest.summary.files_deleted, 1);
assert_eq!(manifest.summary.total_files_changed, 3);
assert!(manifest.summary.total_lines_added > 0);
assert!(manifest.summary.total_lines_removed > 0);
}
#[test]
fn it_uses_empty_base_for_added_dep_file_in_paginated_mode() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("seed.txt"), "seed\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\n[dependencies]\nserde = \"1\"\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add cargo"])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 0, 1).unwrap();
assert!(
!manifest.dependency_changes.is_empty(),
"dependency changes should detect added Cargo.toml"
);
let dep = &manifest.dependency_changes[0];
assert!(!dep.added.is_empty(), "should have added dependencies");
}
#[test]
fn it_returns_exact_page_boundary_files() {
let (_dir, path) = create_repo_with_n_files(3);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 2, 100).unwrap();
assert_eq!(manifest.files.len(), 1, "should return the last file");
let manifest = build_manifest(&path, "HEAD~1", "HEAD", &options, 3, 100).unwrap();
assert!(
manifest.files.is_empty(),
"offset at total should return empty"
);
}
#[test]
fn it_worktree_is_paginating_only_when_total_exceeds_page_size() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("init.txt"), "init\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
for i in 0..3 {
std::fs::write(path.join(format!("file{i}.txt")), format!("content {i}\n")).unwrap();
}
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 3).unwrap();
assert_eq!(manifest.files.len(), 3);
assert!(manifest.pagination.next_cursor.is_none());
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 2).unwrap();
assert_eq!(manifest.files.len(), 2);
assert!(manifest.pagination.next_cursor.is_some());
}
#[test]
fn it_worktree_counts_summary_correctly_with_pagination() {
use std::process::Command;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("modify.txt"), "old\n").unwrap();
std::fs::write(path.join("delete.txt"), "gone\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(path.join("added.txt"), "new\n").unwrap();
std::fs::write(path.join("modify.txt"), "changed\n").unwrap();
std::fs::remove_file(path.join("delete.txt")).unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 0, 1).unwrap();
assert_eq!(manifest.summary.files_added, 1);
assert_eq!(manifest.summary.files_modified, 1);
assert_eq!(manifest.summary.files_deleted, 1);
assert_eq!(manifest.summary.total_files_changed, 3);
assert!(manifest.summary.total_lines_added > 0);
assert!(manifest.summary.total_lines_removed > 0);
}
#[test]
fn it_worktree_returns_exact_page_boundary_files() {
let (_dir, path) = create_worktree_repo_with_n_staged_files(3);
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let manifest = build_worktree_manifest(&path, "HEAD", &options, 2, 100).unwrap();
assert_eq!(manifest.files.len(), 1);
let manifest = build_worktree_manifest(&path, "HEAD", &options, 3, 100).unwrap();
assert!(manifest.files.is_empty());
assert!(manifest.pagination.next_cursor.is_none());
}
fn make_test_file_entry(
path: &str,
with_functions: bool,
with_imports: bool,
) -> ManifestFileEntry {
ManifestFileEntry {
path: path.to_string(),
old_path: None,
change_type: ChangeType::Modified,
change_scope: ChangeScope::Committed,
language: "rust".to_string(),
is_binary: false,
is_generated: false,
lines_added: 10,
lines_removed: 5,
size_before: 100,
size_after: 120,
functions_changed: if with_functions {
Some(vec![FunctionChange {
name: format!("fn_in_{path}"),
old_name: None,
change_type: FunctionChangeType::Modified,
start_line: 1,
end_line: 10,
signature: format!("pub fn fn_in_{path}()"),
}])
} else {
None
},
imports_changed: if with_imports {
Some(ImportChange {
added: vec!["use std::io".to_string()],
removed: vec![],
})
} else {
None
},
}
}
fn make_test_response(files: Vec<ManifestFileEntry>) -> ManifestResponse {
ManifestResponse {
metadata: ManifestMetadata {
repo_path: "/test".to_string(),
base_ref: "HEAD~1".to_string(),
head_ref: "HEAD".to_string(),
base_sha: "aaa".to_string(),
head_sha: "bbb".to_string(),
generated_at: Utc::now(),
version: "0.0.0".to_string(),
token_estimate: 0,
function_analysis_truncated: vec![],
budget_tokens: None,
},
summary: ManifestSummary {
total_files_changed: files.len(),
files_added: 0,
files_modified: files.len(),
files_deleted: 0,
files_renamed: 0,
total_lines_added: 0,
total_lines_removed: 0,
total_functions_changed: None,
languages_affected: vec!["rust".to_string()],
},
files,
dependency_changes: vec![],
pagination: PaginationInfo {
total_items: 0,
page_start: 0,
page_size: 100,
next_cursor: None,
},
}
}
#[test]
fn enforce_token_budget_under_budget_returns_empty() {
let files = vec![
make_test_file_entry("a.rs", true, true),
make_test_file_entry("b.rs", true, true),
];
let mut response = make_test_response(files);
let trimmed = enforce_token_budget(&mut response, 100_000);
assert!(
trimmed.is_empty(),
"nothing should be trimmed under a large budget"
);
for file in &response.files {
assert!(file.functions_changed.is_some());
assert!(file.imports_changed.is_some());
}
}
#[test]
fn enforce_token_budget_over_budget_trims_imports_first() {
let files = vec![
make_test_file_entry("a.rs", true, true),
make_test_file_entry("b.rs", true, true),
make_test_file_entry("c.rs", true, true),
];
let mut response = make_test_response(files);
let skeleton_cost = {
let files_saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = files_saved;
cost
};
let first_file_cost = size::estimate_response_tokens(&response.files[0]);
let budget = skeleton_cost + first_file_cost + first_file_cost / 2;
let trimmed = enforce_token_budget(&mut response, budget);
assert!(
!trimmed.is_empty(),
"at least one file should be listed as tier-1 trimmed"
);
for path in &trimmed {
let file = response.files.iter().find(|f| &f.path == path).unwrap();
assert!(
file.functions_changed.is_some(),
"trimmed file {path} must retain function signatures"
);
assert!(
file.imports_changed.is_none(),
"trimmed file {path} must have imports stripped"
);
}
}
#[test]
fn enforce_token_budget_very_tight_strips_to_bare() {
let files = vec![
make_test_file_entry("a.rs", true, true),
make_test_file_entry("b.rs", true, true),
];
let mut response = make_test_response(files);
let skeleton_cost = {
let files_saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = files_saved;
cost
};
let budget = skeleton_cost + 10; let trimmed = enforce_token_budget(&mut response, budget);
for file in &response.files {
assert!(
file.functions_changed.is_none(),
"file {} should be bare (tier 2)",
file.path
);
assert!(file.imports_changed.is_none());
}
assert!(
trimmed.is_empty(),
"tier 2 files must not appear in trimmed list"
);
}
#[test]
fn enforce_token_budget_zero_budget_returns_empty() {
let files = vec![make_test_file_entry("a.rs", true, true)];
let mut response = make_test_response(files);
let trimmed = enforce_token_budget(&mut response, 0);
assert!(
trimmed.is_empty(),
"zero budget produces no tier-1 trimmed paths"
);
assert!(
response.files.is_empty(),
"zero budget drops all files from the response"
);
}
#[test]
fn enforce_token_budget_trimmed_list_excludes_tier2_files() {
let files = (0..10)
.map(|i| make_test_file_entry(&format!("file_{i}.rs"), true, true))
.collect::<Vec<_>>();
let mut response = make_test_response(files);
let total_cost = size::estimate_response_tokens(&response);
let budget = total_cost / 2;
let trimmed = enforce_token_budget(&mut response, budget);
for path in &trimmed {
let file = response.files.iter().find(|f| &f.path == path).unwrap();
assert!(
file.functions_changed.is_some(),
"trimmed file {path} listed in function_analysis_truncated must have functions_changed"
);
assert!(
file.imports_changed.is_none(),
"trimmed file {path} should have imports stripped"
);
}
for file in &response.files {
if file.functions_changed.is_none() {
assert!(
!trimmed.contains(&file.path),
"tier 2 file {} must not be in trimmed list",
file.path
);
}
}
}
#[test]
fn it_uses_division_not_modulo_for_safety_margin() {
let files = (0..80)
.map(|i| make_test_file_entry(&format!("f{i}.rs"), true, true))
.collect::<Vec<_>>();
let mut response = make_test_response(files);
let skeleton_cost = {
let saved = std::mem::take(&mut response.files);
let token_count = size::estimate_response_tokens(&response);
response.files = saved;
token_count
};
let per_file_full_total: usize = response
.files
.iter()
.map(size::estimate_response_tokens)
.sum();
let target = skeleton_cost + per_file_full_total;
let lower = target + 16;
let upper = target * 20 / 19; assert!(
upper > lower,
"fixture must be large enough for the safety-margin sweet spot \
to exist: target={target} needs target/19 > 16, i.e. target > 304",
);
let budget = (lower + upper) / 2;
let margin_div = (budget / 20).max(16);
let margin_mod = (budget % 20).max(16);
assert!(
margin_div > margin_mod,
"test presumes budget/20 > budget%20 (clamped); \
budget={budget} /20={margin_div} %20={margin_mod}",
);
let file_budget_under_div = budget
.saturating_sub(skeleton_cost)
.saturating_sub(margin_div);
let file_budget_under_mod = budget
.saturating_sub(skeleton_cost)
.saturating_sub(margin_mod);
assert!(
file_budget_under_div < per_file_full_total,
"test construction invalid: under correct `/`, file_budget \
({file_budget_under_div}) must be < per_file_full_total \
({per_file_full_total}) so trimming is required. \
budget={budget} skeleton={skeleton_cost} margin_div={margin_div}",
);
assert!(
file_budget_under_mod >= per_file_full_total,
"test construction invalid: under mutant `%`, file_budget \
({file_budget_under_mod}) must be ≥ per_file_full_total \
({per_file_full_total}) so no trimming occurs. \
budget={budget} skeleton={skeleton_cost} margin_mod={margin_mod}",
);
let trimmed = enforce_token_budget(&mut response, budget);
let some_imports_stripped = response.files.iter().any(|f| f.imports_changed.is_none());
assert!(
!trimmed.is_empty(),
"with budget {budget} (skeleton={skeleton_cost}, \
per_file_full_total={per_file_full_total}) the /20 safety margin \
of {margin_div} tokens must force trimming; a modulo-based \
margin would be clamped to {margin_mod} and leave room for every file"
);
assert!(
some_imports_stripped,
"trimming must strip imports from at least one file; \
budget={budget} margin_div={margin_div}"
);
}
#[test]
fn it_decreases_remaining_budget_after_each_tier1_decision() {
let files = (0..6)
.map(|i| make_test_file_entry(&format!("f{i}.rs"), true, true))
.collect::<Vec<_>>();
let mut response = make_test_response(files);
let skeleton_cost = {
let saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = saved;
cost
};
let one_tier1 = {
let mut clone = response.files[0].clone();
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
};
let raw_budget = skeleton_cost + one_tier1 * 3;
let budget = raw_budget + raw_budget / 4;
let safety_margin = (budget / 20).max(16);
let file_budget = budget
.saturating_sub(skeleton_cost)
.saturating_sub(safety_margin);
let total_tier1: usize = response
.files
.iter()
.map(|f| {
let mut clone = f.clone();
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
})
.sum();
assert!(
total_tier1 > file_budget,
"construction invalid: greedy walk must execute; \
total_tier1={total_tier1} file_budget={file_budget} \
budget={budget} skeleton_cost={skeleton_cost} \
one_tier1={one_tier1}",
);
let trimmed = enforce_token_budget(&mut response, budget);
let tier1_count = trimmed.len();
let bare_count = response
.files
.iter()
.filter(|f| f.functions_changed.is_none())
.count();
assert!(
tier1_count >= 1,
"at least one file must survive at tier1 (functions kept, imports \
stripped) after a partial-fit budget; tier1_count={tier1_count}, \
bare_count={bare_count}",
);
assert!(
tier1_count >= 2,
"at least two files must survive at tier1; under a /= mutant, remaining \
collapses to ~1 after the first tier1 decision so only one file can \
stay at tier1: tier1_count={tier1_count}",
);
assert!(
bare_count >= 1,
"at least one file must fall to bare once the remaining budget \
is drained by earlier tier1 decisions; tier1_count={tier1_count}, \
bare_count={bare_count}. If every file stayed at tier1, remaining \
is not being decreased (mutant: `remaining += c.tier1`)",
);
}
#[test]
fn it_decreases_remaining_budget_after_each_bare_decision() {
fn make_many_fn_entry(path: &str, fn_count: usize) -> ManifestFileEntry {
let functions = (0..fn_count)
.map(|i| FunctionChange {
name: format!("really_long_function_name_number_{i}"),
old_name: None,
change_type: FunctionChangeType::Modified,
start_line: i * 10,
end_line: i * 10 + 5,
signature: format!(
"pub fn really_long_function_name_number_{i}\
(arg_alpha: i64, arg_beta: String) -> Result<usize, Box<dyn Error>>"
),
})
.collect();
ManifestFileEntry {
path: path.to_string(),
old_path: None,
change_type: ChangeType::Modified,
change_scope: ChangeScope::Committed,
language: "rust".to_string(),
is_binary: false,
is_generated: false,
lines_added: 10,
lines_removed: 5,
size_before: 100,
size_after: 120,
functions_changed: Some(functions),
imports_changed: None, }
}
let files = (0..8)
.map(|i| make_many_fn_entry(&format!("f{i}.rs"), 10))
.collect::<Vec<_>>();
let mut response = make_test_response(files);
let skeleton_cost = {
let saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = saved;
cost
};
let one_full = size::estimate_response_tokens(&response.files[0]);
let one_bare = {
let mut clone = response.files[0].clone();
clone.functions_changed = None;
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
};
assert!(
one_full >= 4 * one_bare,
"fixture construction requires full >> bare to leave room for \
a tight budget that admits several bare files but no tier1 \
file: full={one_full} bare={one_bare}",
);
let target_file_budget = (3 * one_bare + one_full - 1) / 2;
let budget = ((skeleton_cost + target_file_budget) * 20).div_ceil(19);
let safety_margin = (budget / 20).max(16);
let file_budget = budget
.saturating_sub(skeleton_cost)
.saturating_sub(safety_margin);
assert!(
file_budget < one_full,
"construction invalid: tier1 branch would fire for the first \
file (file_budget={file_budget} >= one_full={one_full})",
);
assert!(
file_budget >= 2 * one_bare,
"construction invalid: fewer than 2 bare files fit \
(file_budget={file_budget} < 2*one_bare={})",
2 * one_bare,
);
let files_before = response.files.len();
let _ = enforce_token_budget(&mut response, budget);
let files_after = response.files.len();
assert!(
files_after < files_before,
"with a tight budget that fits only a few bare entries, \
enforcement must drop files from the 8-file input: \
files_before={files_before} files_after={files_after}. \
If all files survive, remaining is not being decreased \
(mutant: `remaining += c.bare`)",
);
assert!(
files_after >= 2,
"at least two files should fit bare with this budget; \
files_after={files_after} suggests remaining collapsed to near \
zero after a single decision (mutant: `remaining /= c.bare`)",
);
for file in &response.files {
assert!(
file.functions_changed.is_none(),
"file {} survived at tier1 but construction forces bare path \
(file_budget={file_budget} one_full={one_full})",
file.path,
);
}
}
#[test]
fn it_returns_empty_trimmed_and_keeps_all_files_when_everything_fits() {
let files = vec![
make_test_file_entry("a.rs", true, true),
make_test_file_entry("b.rs", true, true),
];
let mut response = make_test_response(files);
let files_before: Vec<_> = response.files.iter().map(|f| f.path.clone()).collect();
let skeleton_cost = {
let saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = saved;
cost
};
let per_file_full_total: usize = response
.files
.iter()
.map(size::estimate_response_tokens)
.sum();
let budget = (skeleton_cost + per_file_full_total) * 100;
let trimmed = enforce_token_budget(&mut response, budget);
assert!(trimmed.is_empty(), "nothing trimmed under huge budget");
assert_eq!(
response.files.len(),
files_before.len(),
"no files dropped under huge budget"
);
for (i, f) in response.files.iter().enumerate() {
assert_eq!(f.path, files_before[i], "file order unchanged");
assert!(
f.functions_changed.is_some(),
"functions_changed preserved for {}",
f.path
);
assert!(
f.imports_changed.is_some(),
"imports_changed preserved for {}",
f.path
);
}
}
#[test]
fn it_strips_imports_only_from_files_that_had_them_at_tier1_fast_path() {
let files = vec![
make_test_file_entry("has_imports.rs", true, true),
make_test_file_entry("no_imports.rs", true, false),
];
let mut response = make_test_response(files);
let skeleton_cost = {
let saved = std::mem::take(&mut response.files);
let cost = size::estimate_response_tokens(&response);
response.files = saved;
cost
};
let total_full: usize = response
.files
.iter()
.map(size::estimate_response_tokens)
.sum();
let total_tier1: usize = response
.files
.iter()
.map(|f| {
let mut clone = f.clone();
clone.imports_changed = None;
size::estimate_response_tokens(&clone)
})
.sum();
assert!(
total_tier1 < total_full,
"fixture must include a file with imports so total_tier1 \
({total_tier1}) < total_full ({total_full})",
);
let mid_file_budget = total_tier1 + (total_full - total_tier1) / 2 + 1;
let budget = ((skeleton_cost + mid_file_budget) * 20).div_ceil(19);
let safety_margin = (budget / 20).max(16);
let file_budget = budget
.saturating_sub(skeleton_cost)
.saturating_sub(safety_margin);
assert!(
total_full > file_budget,
"construction invalid: total_full ({total_full}) must exceed \
file_budget ({file_budget}) to skip the all-full fast path",
);
assert!(
total_tier1 <= file_budget,
"construction invalid: total_tier1 ({total_tier1}) must fit in \
file_budget ({file_budget}) to enter the tier1 fast path",
);
let trimmed = enforce_token_budget(&mut response, budget);
assert_eq!(trimmed.len(), 1, "exactly one file should be trimmed");
assert!(
trimmed.contains(&"has_imports.rs".to_string()),
"has_imports.rs must appear in the trimmed list; trimmed={trimmed:?}"
);
assert!(
!trimmed.contains(&"no_imports.rs".to_string()),
"no_imports.rs was never altered and must not appear; trimmed={trimmed:?}"
);
let no_imports_entry = response
.files
.iter()
.find(|f| f.path == "no_imports.rs")
.expect("no_imports.rs must still be present");
assert!(
no_imports_entry.functions_changed.is_some(),
"no_imports.rs should keep its function signatures at tier1"
);
}
}