返回 DeepSeek-TUI-2026
review.rs
根目录 / crates / tui / src / tools / review.rs
1 //! Tool for structured code reviews of files, diffs, or pull requests.
2
3 use std::fs;
4 use std::path::Path;
5 use std::process::Command;
6
7 use async_trait::async_trait;
8 use serde::{Deserialize, Serialize};
9 use serde_json::{Value, json};
10
11 use crate::client::DeepSeekClient;
12 use crate::llm_client::LlmClient;
13 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
14 use crate::utils::truncate_with_ellipsis;
15
16 use super::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 optional_bool, optional_str, optional_u64, required_str,
19 };
20
21 const DEFAULT_MAX_CHARS: usize = 200_000;
22 const MAX_MAX_CHARS: usize = 1_000_000;
23 const REVIEW_MAX_TOKENS: u32 = 2048;
24 const FALLBACK_MAX_CHARS: usize = 4000;
25
26 const REVIEW_SYSTEM_PROMPT: &str = "You are a senior code reviewer. Return ONLY valid JSON with \
27 the following schema:\n\
28 {\n\
29 \"summary\": \"short overview\",\n\
30 \"issues\": [\n\
31 {\n\
32 \"severity\": \"error|warning|info\",\n\
33 \"title\": \"issue title\",\n\
34 \"description\": \"details and impact\",\n\
35 \"path\": \"relative/file/path or null\",\n\
36 \"line\": 123\n\
37 }\n\
38 ],\n\
39 \"suggestions\": [\n\
40 {\n\
41 \"path\": \"relative/file/path or null\",\n\
42 \"line\": 123,\n\
43 \"suggestion\": \"actionable improvement\"\n\
44 }\n\
45 ],\n\
46 \"overall_assessment\": \"final assessment\"\n\
47 }\n\
48 If a field is unknown, use an empty string or null. Prioritize correctness and missing tests.";
49
50 #[derive(Debug, Clone, Serialize, Deserialize)]
51 pub struct ReviewIssue {
52 #[serde(default)]
53 pub severity: String,
54 #[serde(default)]
55 pub title: String,
56 #[serde(default)]
57 pub description: String,
58 #[serde(default)]
59 pub path: Option<String>,
60 #[serde(default)]
61 pub line: Option<u32>,
62 }
63
64 #[derive(Debug, Clone, Serialize, Deserialize)]
65 pub struct ReviewSuggestion {
66 #[serde(default)]
67 pub path: Option<String>,
68 #[serde(default)]
69 pub line: Option<u32>,
70 #[serde(default)]
71 pub suggestion: String,
72 }
73
74 #[derive(Debug, Clone, Serialize, Deserialize)]
75 pub struct ReviewOutput {
76 #[serde(default)]
77 pub summary: String,
78 #[serde(default)]
79 pub issues: Vec<ReviewIssue>,
80 #[serde(default)]
81 pub suggestions: Vec<ReviewSuggestion>,
82 #[serde(default)]
83 pub overall_assessment: String,
84 }
85
86 impl ReviewOutput {
87 #[must_use]
88 pub fn from_str(raw: &str) -> Self {
89 if let Ok(parsed) = serde_json::from_str::<ReviewOutput>(raw) {
90 return parsed.normalize();
91 }
92 if let Some(json_block) = extract_json_block(raw)
93 && let Ok(parsed) = serde_json::from_str::<ReviewOutput>(json_block)
94 {
95 return parsed.normalize();
96 }
97 ReviewOutput::fallback(raw)
98 }
99
100 fn fallback(raw: &str) -> Self {
101 let trimmed = raw.trim();
102 let summary = if trimmed.is_empty() {
103 "Review completed but no structured output was returned.".to_string()
104 } else {
105 truncate_with_ellipsis(trimmed, FALLBACK_MAX_CHARS, "\n...[truncated]\n")
106 };
107 Self {
108 summary,
109 issues: Vec::new(),
110 suggestions: Vec::new(),
111 overall_assessment: String::new(),
112 }
113 }
114
115 fn normalize(mut self) -> Self {
116 self.summary = self.summary.trim().to_string();
117 self.overall_assessment = self.overall_assessment.trim().to_string();
118 for issue in &mut self.issues {
119 issue.severity = normalize_severity(&issue.severity);
120 issue.title = issue.title.trim().to_string();
121 issue.description = issue.description.trim().to_string();
122 issue.path = normalize_optional(issue.path.take());
123 }
124 for suggestion in &mut self.suggestions {
125 suggestion.suggestion = suggestion.suggestion.trim().to_string();
126 suggestion.path = normalize_optional(suggestion.path.take());
127 }
128 self
129 }
130 }
131
132 pub struct ReviewTool {
133 client: Option<DeepSeekClient>,
134 model: String,
135 }
136
137 impl ReviewTool {
138 #[must_use]
139 pub fn new(client: Option<DeepSeekClient>, model: String) -> Self {
140 Self { client, model }
141 }
142 }
143
144 #[async_trait]
145 impl ToolSpec for ReviewTool {
146 fn name(&self) -> &'static str {
147 "review"
148 }
149
150 fn description(&self) -> &'static str {
151 "Run a structured code review for a file, git diff, or GitHub pull request."
152 }
153
154 fn input_schema(&self) -> Value {
155 json!({
156 "type": "object",
157 "properties": {
158 "target": {
159 "type": "string",
160 "description": "File path, PR URL, or the literal 'diff'/'staged' for git diff review."
161 },
162 "kind": {
163 "type": "string",
164 "description": "Optional explicit target type: file, diff, or pr."
165 },
166 "base": {
167 "type": "string",
168 "description": "Optional git base ref when using diff target (e.g. origin/main)."
169 },
170 "staged": {
171 "type": "boolean",
172 "description": "Review staged changes when using diff target (default: false)."
173 },
174 "max_chars": {
175 "type": "integer",
176 "description": "Maximum characters to include from the source (default: 200000)."
177 }
178 },
179 "required": ["target"]
180 })
181 }
182
183 fn capabilities(&self) -> Vec<ToolCapability> {
184 vec![ToolCapability::ReadOnly, ToolCapability::Network]
185 }
186
187 fn approval_requirement(&self) -> ApprovalRequirement {
188 ApprovalRequirement::Auto
189 }
190
191 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
192 let Some(client) = self.client.clone() else {
193 return Err(ToolError::not_available(
194 "Review tool requires an active DeepSeek client".to_string(),
195 ));
196 };
197
198 let target = required_str(&input, "target")?.trim();
199 if target.is_empty() {
200 return Err(ToolError::invalid_input("target cannot be empty"));
201 }
202
203 let kind = optional_str(&input, "kind").map(|s| s.trim().to_ascii_lowercase());
204 let base = optional_str(&input, "base").map(|s| s.trim().to_string());
205 let staged = optional_bool(&input, "staged", false);
206 let max_chars =
207 usize::try_from(optional_u64(&input, "max_chars", DEFAULT_MAX_CHARS as u64))
208 .unwrap_or(DEFAULT_MAX_CHARS)
209 .clamp(1, MAX_MAX_CHARS);
210
211 let source =
212 resolve_review_source(target, kind.as_deref(), staged, base.as_deref(), context)?;
213 let prompt = build_review_prompt(&source, max_chars);
214
215 let request = MessageRequest {
216 model: self.model.clone(),
217 messages: vec![Message {
218 role: "user".to_string(),
219 content: vec![ContentBlock::Text {
220 text: prompt,
221 cache_control: None,
222 }],
223 }],
224 max_tokens: REVIEW_MAX_TOKENS,
225 system: Some(SystemPrompt::Text(REVIEW_SYSTEM_PROMPT.to_string())),
226 tools: None,
227 tool_choice: None,
228 metadata: None,
229 thinking: None,
230 reasoning_effort: None,
231 stream: Some(false),
232 temperature: Some(0.2),
233 top_p: Some(0.9),
234 };
235
236 let response = client
237 .create_message(request)
238 .await
239 .map_err(|e| ToolError::execution_failed(format!("Review request failed: {e}")))?;
240
241 let response_text = extract_text(&response.content);
242 let output = ReviewOutput::from_str(&response_text);
243 let metadata = review_usage_metadata(&response.model, &response.usage);
244 let result =
245 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))?;
246 Ok(result.with_metadata(metadata))
247 }
248 }
249
250 fn review_usage_metadata(model: &str, usage: &Usage) -> Value {
251 json!({
252 "tool": "review",
253 "input_tokens": usage.input_tokens,
254 "output_tokens": usage.output_tokens,
255 "child_model": model,
256 "child_input_tokens": usage.input_tokens,
257 "child_output_tokens": usage.output_tokens,
258 "child_prompt_cache_hit_tokens": usage.prompt_cache_hit_tokens,
259 "child_prompt_cache_miss_tokens": usage.prompt_cache_miss_tokens,
260 "child_reasoning_tokens": usage.reasoning_tokens,
261 })
262 }
263
264 enum ReviewSource {
265 File { display: String, content: String },
266 Diff { label: String, diff: String },
267 PullRequest { label: String, diff: String },
268 }
269
270 fn resolve_review_source(
271 target: &str,
272 kind: Option<&str>,
273 staged: bool,
274 base: Option<&str>,
275 context: &ToolContext,
276 ) -> Result<ReviewSource, ToolError> {
277 if let Some(kind) = kind {
278 return match kind {
279 "file" => resolve_file_target(target, context),
280 "diff" => resolve_diff_target(context.workspace.as_path(), staged, base).map(|diff| {
281 ReviewSource::Diff {
282 label: "git diff".to_string(),
283 diff,
284 }
285 }),
286 "pr" | "pull" | "pull_request" => {
287 let pr = parse_pr_url(target)
288 .ok_or_else(|| ToolError::invalid_input("Invalid pull request URL"))?;
289 let diff = gh_pr_diff(&pr, &context.workspace)?;
290 Ok(ReviewSource::PullRequest {
291 label: pr.label(),
292 diff,
293 })
294 }
295 other => Err(ToolError::invalid_input(format!(
296 "Unknown review kind '{other}'"
297 ))),
298 };
299 }
300
301 if let Some(pr) = parse_pr_url(target) {
302 let diff = gh_pr_diff(&pr, &context.workspace)?;
303 return Ok(ReviewSource::PullRequest {
304 label: pr.label(),
305 diff,
306 });
307 }
308
309 if let Some(staged_override) = diff_mode_from_target(target) {
310 let staged = staged || staged_override;
311 let diff = resolve_diff_target(context.workspace.as_path(), staged, base)?;
312 return Ok(ReviewSource::Diff {
313 label: if staged {
314 "git diff --cached"
315 } else {
316 "git diff"
317 }
318 .to_string(),
319 diff,
320 });
321 }
322
323 resolve_file_target(target, context)
324 }
325
326 fn resolve_file_target(target: &str, context: &ToolContext) -> Result<ReviewSource, ToolError> {
327 let path = context.resolve_path(target)?;
328 if !path.is_file() {
329 return Err(ToolError::invalid_input(format!(
330 "Target is not a file: {}",
331 path.display()
332 )));
333 }
334 let content = fs::read_to_string(&path).map_err(|e| {
335 ToolError::execution_failed(format!("Failed to read file {}: {e}", path.display()))
336 })?;
337 let display = path
338 .strip_prefix(&context.workspace)
339 .unwrap_or(&path)
340 .to_string_lossy()
341 .to_string();
342 Ok(ReviewSource::File { display, content })
343 }
344
345 fn resolve_diff_target(
346 workspace: &Path,
347 staged: bool,
348 base: Option<&str>,
349 ) -> Result<String, ToolError> {
350 let mut cmd = Command::new("git");
351 cmd.arg("diff");
352 if staged {
353 cmd.arg("--cached");
354 }
355 if let Some(base) = base
356 && !base.trim().is_empty()
357 {
358 cmd.arg(format!("{base}...HEAD"));
359 }
360 cmd.current_dir(workspace);
361
362 let output = cmd
363 .output()
364 .map_err(|e| ToolError::execution_failed(format!("Failed to run git diff: {e}")))?;
365 if !output.status.success() {
366 let stderr = String::from_utf8_lossy(&output.stderr);
367 return Err(ToolError::execution_failed(format!(
368 "git diff failed: {}",
369 stderr.trim()
370 )));
371 }
372 let diff = String::from_utf8_lossy(&output.stdout).to_string();
373 if diff.trim().is_empty() {
374 return Err(ToolError::invalid_input("No diff to review"));
375 }
376 Ok(diff)
377 }
378
379 fn gh_pr_diff(pr: &PullRequestRef, workspace: &Path) -> Result<String, ToolError> {
380 let mut cmd = Command::new("gh");
381 cmd.arg("pr")
382 .arg("diff")
383 .arg(&pr.number)
384 .arg("--repo")
385 .arg(format!("{}/{}", pr.owner, pr.repo))
386 .current_dir(workspace);
387
388 let output = cmd.output().map_err(|e| {
389 ToolError::execution_failed(format!("Failed to run gh pr diff (is gh installed?): {e}"))
390 })?;
391 if !output.status.success() {
392 let stderr = String::from_utf8_lossy(&output.stderr);
393 return Err(ToolError::execution_failed(format!(
394 "gh pr diff failed: {}",
395 stderr.trim()
396 )));
397 }
398 let diff = String::from_utf8_lossy(&output.stdout).to_string();
399 if diff.trim().is_empty() {
400 return Err(ToolError::invalid_input("Pull request diff is empty."));
401 }
402 Ok(diff)
403 }
404
405 fn build_review_prompt(source: &ReviewSource, max_chars: usize) -> String {
406 match source {
407 ReviewSource::File {
408 display, content, ..
409 } => {
410 let numbered = format_with_line_numbers(content);
411 let truncated = truncate_with_ellipsis(&numbered, max_chars, "\n...[truncated]\n");
412 format!(
413 "Review the following file and provide feedback.\n\
414 Path: {display}\n\n{truncated}\n\nEnd of file."
415 )
416 }
417 ReviewSource::Diff { label, diff } => {
418 let truncated = truncate_with_ellipsis(diff, max_chars, "\n...[truncated]\n");
419 format!(
420 "Review the following {label} and provide feedback.\n\n{truncated}\n\nEnd of diff."
421 )
422 }
423 ReviewSource::PullRequest { label, diff } => {
424 let truncated = truncate_with_ellipsis(diff, max_chars, "\n...[truncated]\n");
425 format!(
426 "Review the following pull request diff ({label}) and provide feedback.\n\n{truncated}\n\nEnd of diff."
427 )
428 }
429 }
430 }
431
432 fn format_with_line_numbers(content: &str) -> String {
433 content
434 .lines()
435 .enumerate()
436 .map(|(idx, line)| format!("{:>4} | {}", idx + 1, line))
437 .collect::<Vec<_>>()
438 .join("\n")
439 }
440
441 fn extract_text(blocks: &[ContentBlock]) -> String {
442 let mut output = String::new();
443 for block in blocks {
444 if let ContentBlock::Text { text, .. } = block {
445 if !output.is_empty() {
446 output.push('\n');
447 }
448 output.push_str(text);
449 }
450 }
451 output.trim().to_string()
452 }
453
454 fn normalize_optional(value: Option<String>) -> Option<String> {
455 value
456 .map(|v| v.trim().to_string())
457 .filter(|v| !v.is_empty())
458 }
459
460 fn normalize_severity(value: &str) -> String {
461 let lower = value.trim().to_ascii_lowercase();
462 if lower.starts_with("err") || lower == "critical" || lower == "high" {
463 "error".to_string()
464 } else if lower.starts_with("warn") || lower == "medium" {
465 "warning".to_string()
466 } else {
467 "info".to_string()
468 }
469 }
470
471 fn extract_json_block(raw: &str) -> Option<&str> {
472 let start = raw.find('{')?;
473 let end = raw.rfind('}')?;
474 if end <= start {
475 None
476 } else {
477 Some(&raw[start..=end])
478 }
479 }
480
481 fn diff_mode_from_target(target: &str) -> Option<bool> {
482 match target.trim().to_ascii_lowercase().as_str() {
483 "diff" | "git diff" | "changes" | "working tree" | "working-tree" => Some(false),
484 "staged" | "cached" | "git diff --cached" | "git diff --staged" => Some(true),
485 _ => None,
486 }
487 }
488
489 #[derive(Debug, Clone)]
490 struct PullRequestRef {
491 owner: String,
492 repo: String,
493 number: String,
494 }
495
496 impl PullRequestRef {
497 fn label(&self) -> String {
498 format!("{}/{}#{}", self.owner, self.repo, self.number)
499 }
500 }
501
502 fn parse_pr_url(url: &str) -> Option<PullRequestRef> {
503 let trimmed = url.trim().trim_end_matches('/');
504 if !trimmed.starts_with("http") {
505 return None;
506 }
507 let parts: Vec<&str> = trimmed.split('/').collect();
508 let pull_idx = parts.iter().position(|part| *part == "pull")?;
509 if pull_idx < 2 || pull_idx + 1 >= parts.len() {
510 return None;
511 }
512 let owner = parts.get(pull_idx.saturating_sub(2))?;
513 let repo = parts.get(pull_idx.saturating_sub(1))?;
514 let number = parts.get(pull_idx + 1)?;
515 if owner.is_empty() || repo.is_empty() || number.is_empty() {
516 return None;
517 }
518 Some(PullRequestRef {
519 owner: (*owner).to_string(),
520 repo: (*repo).to_string(),
521 number: (*number).to_string(),
522 })
523 }
524
525 #[cfg(test)]
526 mod tests {
527 use super::*;
528
529 #[test]
530 fn parses_pr_url() {
531 let pr =
532 parse_pr_url("https://github.com/deepseek-ai/deepseek-cli/pull/123").expect("parse pr");
533 assert_eq!(pr.owner, "deepseek-ai");
534 assert_eq!(pr.repo, "deepseek-cli");
535 assert_eq!(pr.number, "123");
536 }
537
538 #[test]
539 fn ignores_non_pr_url() {
540 assert!(parse_pr_url("https://github.com/deepseek-ai/deepseek-cli").is_none());
541 assert!(parse_pr_url("not-a-url").is_none());
542 }
543
544 #[test]
545 fn extracts_json_block() {
546 let raw = "prefix {\"summary\":\"ok\"} suffix";
547 let block = extract_json_block(raw).expect("block");
548 assert!(block.contains("\"summary\""));
549 }
550
551 #[test]
552 fn review_output_fallback_keeps_summary() {
553 let output = ReviewOutput::from_str("Not JSON");
554 assert!(!output.summary.is_empty());
555 assert!(output.issues.is_empty());
556 }
557
558 #[test]
559 fn review_usage_metadata_reports_child_tokens_for_cost_accrual() {
560 let metadata = review_usage_metadata(
561 "deepseek-v4-flash",
562 &Usage {
563 input_tokens: 123,
564 output_tokens: 45,
565 prompt_cache_hit_tokens: Some(100),
566 prompt_cache_miss_tokens: Some(23),
567 reasoning_tokens: Some(7),
568 ..Default::default()
569 },
570 );
571
572 assert_eq!(metadata["tool"], "review");
573 assert_eq!(metadata["child_model"], "deepseek-v4-flash");
574 assert_eq!(metadata["child_input_tokens"], 123);
575 assert_eq!(metadata["child_output_tokens"], 45);
576 assert_eq!(metadata["child_prompt_cache_hit_tokens"], 100);
577 assert_eq!(metadata["child_prompt_cache_miss_tokens"], 23);
578 assert_eq!(metadata["child_reasoning_tokens"], 7);
579 }
580 }
581
581 lines RUST