use std::path::Path;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::tools::context::{ContextOptions, build_function_context_with_options};
use crate::tools::manifest::{build_manifest, build_worktree_manifest};
use crate::tools::types::{FunctionContextResponse, ManifestOptions, ManifestResponse, ToolError};
const DEFAULT_REVIEW_CHANGE_BUDGET: usize = 8192;
const DEFAULT_REVIEW_CHANGE_PAGE_SIZE: usize = 25;
fn default_review_change_budget() -> usize {
DEFAULT_REVIEW_CHANGE_BUDGET
}
fn default_review_change_page_size() -> usize {
DEFAULT_REVIEW_CHANGE_PAGE_SIZE
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ReviewChangeArgs {
pub repo_path: Option<String>,
pub base_ref: String,
pub head_ref: Option<String>,
#[serde(default)]
pub include_patterns: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub function_names: Option<Vec<String>>,
#[serde(default = "default_review_change_budget")]
pub max_response_tokens: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub function_context_cursor: Option<String>,
#[serde(default = "default_review_change_page_size")]
pub page_size: usize,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ReviewChangeResponse {
pub manifest: ManifestResponse,
pub function_context: FunctionContextResponse,
}
#[must_use]
pub fn split_budget(budget: usize) -> (usize, usize) {
if budget == 0 {
return (0, 0);
}
let budget_f = budget as f64;
let manifest_share = (budget_f * 0.4) as usize;
let context_share = (budget_f * 0.6).round() as usize;
(manifest_share, context_share)
}
fn budget_to_option(value: usize) -> Option<usize> {
if value == 0 { None } else { Some(value) }
}
pub fn build_review_change(
repo_path: &Path,
args: ReviewChangeArgs,
) -> Result<ReviewChangeResponse, ToolError> {
let (manifest_budget, context_budget) = split_budget(args.max_response_tokens);
let manifest_options = ManifestOptions {
include_patterns: args.include_patterns.clone(),
exclude_patterns: args.exclude_patterns.clone(),
include_function_analysis: true,
max_response_tokens: budget_to_option(manifest_budget),
};
let manifest_offset = if let Some(ref cursor) = args.manifest_cursor {
let cursor = crate::pagination::decode_cursor(cursor).map_err(ToolError::InvalidCursor)?;
cursor.offset
} else {
0
};
let page_size = crate::pagination::clamp_page_size(args.page_size);
let mut manifest = match args.head_ref.as_deref() {
Some(head) => {
tracing::debug!(
base_ref = %args.base_ref,
head_ref = %head,
manifest_budget,
"review_change: building manifest half"
);
build_manifest(
repo_path,
&args.base_ref,
head,
&manifest_options,
manifest_offset,
page_size,
)
.inspect_err(|e| {
tracing::error!(
error = %e,
sub_tool = "get_change_manifest",
"review_change: manifest half failed"
);
})?
}
None => {
tracing::debug!(
base_ref = %args.base_ref,
manifest_budget,
"review_change: building worktree manifest half"
);
build_worktree_manifest(
repo_path,
&args.base_ref,
&manifest_options,
manifest_offset,
page_size,
)
.inspect_err(|e| {
tracing::error!(
error = %e,
sub_tool = "get_change_manifest",
"review_change: worktree manifest half failed"
);
})?
}
};
manifest.metadata.budget_tokens = Some(manifest_budget);
let function_context = match args.head_ref.as_deref() {
Some(head) => {
let context_opts = ContextOptions {
cursor: args.function_context_cursor.clone(),
page_size,
function_names: args.function_names.clone(),
max_response_tokens: budget_to_option(context_budget),
};
tracing::debug!(
base_ref = %args.base_ref,
head_ref = %head,
context_budget,
"review_change: building function-context half"
);
let mut response =
build_function_context_with_options(repo_path, &args.base_ref, head, &context_opts)
.inspect_err(|e| {
tracing::error!(
error = %e,
sub_tool = "get_function_context",
"review_change: function-context half failed"
);
})?;
response.metadata.budget_tokens = Some(context_budget);
response
}
None => {
empty_function_context(
args.base_ref.clone(),
manifest.metadata.head_sha.clone(),
manifest.metadata.base_sha.clone(),
context_budget,
)
}
};
Ok(ReviewChangeResponse {
manifest,
function_context,
})
}
fn empty_function_context(
base_ref: String,
head_sha: String,
base_sha: String,
budget: usize,
) -> FunctionContextResponse {
use chrono::Utc;
use crate::pagination::PaginationInfo;
use crate::tools::types::ContextMetadata;
FunctionContextResponse {
metadata: ContextMetadata {
base_ref,
head_ref: "WORKTREE".to_string(),
base_sha,
head_sha,
generated_at: Utc::now(),
token_estimate: 0,
function_analysis_truncated: vec![],
next_cursor: None,
budget_tokens: Some(budget),
},
functions: vec![],
pagination: PaginationInfo {
total_items: 0,
page_start: 0,
page_size: 0,
next_cursor: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_budget_at_4096_yields_1638_manifest_and_2458_context() {
assert_eq!(split_budget(4096), (1638, 2458));
}
#[test]
fn split_budget_at_16384_yields_6553_manifest_and_9830_context() {
assert_eq!(split_budget(16384), (6553, 9830));
}
#[test]
fn split_budget_zero_yields_zero_for_both() {
assert_eq!(split_budget(0), (0, 0));
}
#[test]
fn split_budget_one_yields_zero_manifest_and_one_context() {
assert_eq!(split_budget(1), (0, 1));
}
#[test]
fn budget_to_option_zero_is_none() {
assert_eq!(budget_to_option(0), None);
}
#[test]
fn budget_to_option_positive_is_some() {
assert_eq!(budget_to_option(1638), Some(1638));
}
#[test]
fn review_change_args_deserializes_with_defaults() {
let json = r#"{"base_ref": "main", "head_ref": "HEAD"}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert_eq!(args.base_ref, "main");
assert_eq!(args.head_ref.as_deref(), Some("HEAD"));
assert_eq!(args.max_response_tokens, 8192);
assert_eq!(args.page_size, 25);
assert!(args.manifest_cursor.is_none());
assert!(args.function_context_cursor.is_none());
}
#[test]
fn review_change_args_accepts_separate_cursors() {
let json = r#"{
"base_ref": "main",
"head_ref": "HEAD",
"manifest_cursor": "tok-m",
"function_context_cursor": "tok-fc"
}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert_eq!(args.manifest_cursor.as_deref(), Some("tok-m"));
assert_eq!(args.function_context_cursor.as_deref(), Some("tok-fc"));
}
#[test]
fn split_budget_at_10_yields_correct_floor_and_round() {
assert_eq!(split_budget(10), (4, 6));
}
#[test]
fn split_budget_at_5_exercises_half_token_rounding_boundary() {
assert_eq!(split_budget(5), (2, 3));
}
#[test]
fn split_budget_manifest_plus_context_equals_or_near_budget() {
for budget in [100usize, 999, 4096, 8192, 16384, 65536] {
let (m, c) = split_budget(budget);
let diff = (budget as i64 - m as i64 - c as i64).unsigned_abs();
assert!(
diff <= 1,
"split_budget({budget}) = ({m}, {c}); sum {s} is more than 1 away from budget",
s = m + c,
);
}
}
#[test]
fn review_change_args_deserializes_zero_budget_as_disabled() {
let json = r#"{"base_ref": "main", "head_ref": "HEAD", "max_response_tokens": 0}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert_eq!(args.max_response_tokens, 0);
let (m, c) = split_budget(args.max_response_tokens);
assert_eq!(m, 0);
assert_eq!(c, 0);
}
#[test]
fn review_change_args_deserializes_function_names_filter() {
let json = r#"{
"base_ref": "main",
"head_ref": "HEAD",
"function_names": ["foo", "bar"]
}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert_eq!(
args.function_names.as_deref(),
Some(["foo".to_string(), "bar".to_string()].as_slice())
);
}
#[test]
fn review_change_args_function_names_is_none_when_absent() {
let json = r#"{"base_ref": "main", "head_ref": "HEAD"}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert!(
args.function_names.is_none(),
"function_names must be None when not supplied in JSON"
);
}
#[test]
fn review_change_args_deserializes_custom_page_size() {
let json = r#"{"base_ref": "main", "head_ref": "HEAD", "page_size": 10}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert_eq!(args.page_size, 10);
}
#[test]
fn review_change_args_working_tree_mode_when_head_ref_absent() {
let json = r#"{"base_ref": "HEAD"}"#;
let args: ReviewChangeArgs = serde_json::from_str(json).unwrap();
assert!(
args.head_ref.is_none(),
"head_ref must be None when omitted from JSON (working-tree mode)"
);
}
}