返回 CodeWhale
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, PathBuf};
5
6 use async_trait::async_trait;
7 use chrono::{SecondsFormat, Utc};
8 use serde::{Deserialize, Serialize};
9 use serde_json::{Value, json};
10
11 use crate::client::DeepSeekClient;
12 use crate::dependencies::ExternalTool;
13 use crate::llm_client::LlmClient;
14 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
15 use crate::utils::truncate_with_ellipsis;
16
17 use super::spec::{
18 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
19 optional_bool, optional_str, optional_u64, required_str,
20 };
21
22 const DEFAULT_MAX_CHARS: usize = 200_000;
23 const MAX_MAX_CHARS: usize = 1_000_000;
24 const REVIEW_MAX_TOKENS: u32 = 2048;
25 const FALLBACK_MAX_CHARS: usize = 4000;
26 const REVIEW_RECEIPT_SCHEMA_VERSION: u32 = 1;
27
28 const REVIEW_SYSTEM_PROMPT: &str = "You are a senior code reviewer. Return ONLY valid JSON with \
29 the following schema:\n\
30 {\n\
31 \"summary\": \"short overview\",\n\
32 \"issues\": [\n\
33 {\n\
34 \"severity\": \"error|warning|info\",\n\
35 \"title\": \"issue title\",\n\
36 \"description\": \"details and impact\",\n\
37 \"path\": \"relative/file/path or null\",\n\
38 \"line\": 123\n\
39 }\n\
40 ],\n\
41 \"suggestions\": [\n\
42 {\n\
43 \"path\": \"relative/file/path or null\",\n\
44 \"line\": 123,\n\
45 \"suggestion\": \"actionable improvement\"\n\
46 }\n\
47 ],\n\
48 \"overall_assessment\": \"final assessment\"\n\
49 }\n\
50 If a field is unknown, use an empty string or null. Prioritize correctness and missing tests.";
51
52 #[derive(Debug, Clone, Serialize, Deserialize)]
53 pub struct ReviewIssue {
54 #[serde(default)]
55 pub severity: String,
56 #[serde(default)]
57 pub title: String,
58 #[serde(default)]
59 pub description: String,
60 #[serde(default)]
61 pub path: Option<String>,
62 #[serde(default)]
63 pub line: Option<u32>,
64 }
65
66 #[derive(Debug, Clone, Serialize, Deserialize)]
67 pub struct ReviewSuggestion {
68 #[serde(default)]
69 pub path: Option<String>,
70 #[serde(default)]
71 pub line: Option<u32>,
72 #[serde(default)]
73 pub suggestion: String,
74 }
75
76 #[derive(Debug, Clone, Serialize, Deserialize)]
77 pub struct ReviewOutput {
78 #[serde(default)]
79 pub summary: String,
80 #[serde(default)]
81 pub issues: Vec<ReviewIssue>,
82 #[serde(default)]
83 pub suggestions: Vec<ReviewSuggestion>,
84 #[serde(default)]
85 pub overall_assessment: String,
86 }
87
88 impl ReviewOutput {
89 #[must_use]
90 pub fn from_str(raw: &str) -> Self {
91 if let Some(parsed) = parse_review_output_json(raw) {
92 return parsed.normalize();
93 }
94 if let Some(json_block) = extract_json_block(raw)
95 && let Some(parsed) = parse_review_output_json(json_block)
96 {
97 return parsed.normalize();
98 }
99 ReviewOutput::fallback(raw)
100 }
101
102 fn fallback(raw: &str) -> Self {
103 let trimmed = raw.trim();
104 let summary = if trimmed.is_empty() {
105 "Review completed but no structured output was returned.".to_string()
106 } else {
107 truncate_with_ellipsis(trimmed, FALLBACK_MAX_CHARS, "\n...[truncated]\n")
108 };
109 Self {
110 summary,
111 issues: Vec::new(),
112 suggestions: Vec::new(),
113 overall_assessment: String::new(),
114 }
115 }
116
117 fn normalize(mut self) -> Self {
118 self.summary = self.summary.trim().to_string();
119 self.overall_assessment = self.overall_assessment.trim().to_string();
120 for issue in &mut self.issues {
121 issue.severity = normalize_severity(&issue.severity);
122 issue.title = issue.title.trim().to_string();
123 issue.description = issue.description.trim().to_string();
124 issue.path = normalize_optional(issue.path.take());
125 }
126 for suggestion in &mut self.suggestions {
127 suggestion.suggestion = suggestion.suggestion.trim().to_string();
128 suggestion.path = normalize_optional(suggestion.path.take());
129 }
130 self
131 }
132 }
133
134 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135 pub struct ReviewReceipt {
136 pub schema_version: u32,
137 pub mode: String,
138 pub generated_at: String,
139 pub target: String,
140 pub diff_fingerprint: String,
141 pub diff_bytes: usize,
142 pub diff_lines: usize,
143 pub provider: String,
144 pub model: String,
145 pub checks_run: Vec<ReviewReceiptCheck>,
146 pub findings: ReviewReceiptFindings,
147 pub unresolved_risk: ReviewReceiptRisk,
148 pub review_content_sha256: String,
149 }
150
151 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152 pub struct ReviewReceiptCheck {
153 pub name: String,
154 pub status: String,
155 }
156
157 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158 pub struct ReviewReceiptFindings {
159 pub summary: String,
160 pub issue_count: usize,
161 pub suggestion_count: usize,
162 pub highest_severity: String,
163 pub issues: Vec<ReviewReceiptIssue>,
164 }
165
166 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167 pub struct ReviewReceiptIssue {
168 pub severity: String,
169 pub title: String,
170 pub path: Option<String>,
171 pub line: Option<u32>,
172 }
173
174 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175 pub struct ReviewReceiptRisk {
176 pub unresolved: bool,
177 pub level: String,
178 pub summary: String,
179 }
180
181 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182 pub struct ReviewReceiptValidation {
183 pub passed: bool,
184 pub reason: String,
185 pub diff_fingerprint: String,
186 pub receipt_fingerprint: Option<String>,
187 pub receipt_path: Option<PathBuf>,
188 pub unresolved_risk: Option<ReviewReceiptRisk>,
189 }
190
191 #[must_use]
192 pub fn build_review_receipt(
193 target: impl Into<String>,
194 diff: &str,
195 provider: impl Into<String>,
196 model: impl Into<String>,
197 output: &ReviewOutput,
198 review_content: &str,
199 checks_run: Vec<ReviewReceiptCheck>,
200 ) -> ReviewReceipt {
201 let highest_severity = highest_review_severity(output);
202 let unresolved = !output.issues.is_empty();
203 let risk_level = if unresolved {
204 highest_severity.clone()
205 } else {
206 "none".to_string()
207 };
208 let risk_summary = if unresolved {
209 format!(
210 "{} unresolved review issue(s); highest severity: {highest_severity}",
211 output.issues.len()
212 )
213 } else {
214 "No structured unresolved issues reported by review output.".to_string()
215 };
216
217 ReviewReceipt {
218 schema_version: REVIEW_RECEIPT_SCHEMA_VERSION,
219 mode: "pre_push_review".to_string(),
220 generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
221 target: target.into(),
222 diff_fingerprint: diff_fingerprint(diff),
223 diff_bytes: diff.len(),
224 diff_lines: diff.lines().count(),
225 provider: provider.into(),
226 model: model.into(),
227 checks_run,
228 findings: ReviewReceiptFindings {
229 summary: output.summary.clone(),
230 issue_count: output.issues.len(),
231 suggestion_count: output.suggestions.len(),
232 highest_severity: highest_severity.clone(),
233 issues: output
234 .issues
235 .iter()
236 .map(|issue| ReviewReceiptIssue {
237 severity: issue.severity.clone(),
238 title: issue.title.clone(),
239 path: issue.path.clone(),
240 line: issue.line,
241 })
242 .collect(),
243 },
244 unresolved_risk: ReviewReceiptRisk {
245 unresolved,
246 level: risk_level,
247 summary: risk_summary,
248 },
249 review_content_sha256: sha256_hex(review_content.as_bytes()),
250 }
251 }
252
253 pub fn write_review_receipt(
254 receipt: &ReviewReceipt,
255 path_override: Option<&Path>,
256 ) -> anyhow::Result<PathBuf> {
257 let path = if let Some(path) = path_override {
258 if let Some(parent) = path.parent() {
259 fs::create_dir_all(parent)?;
260 }
261 path.to_path_buf()
262 } else {
263 let dir = codewhale_config::ensure_state_dir("review-receipts")?;
264 let digest = receipt
265 .diff_fingerprint
266 .strip_prefix("sha256:")
267 .unwrap_or(receipt.diff_fingerprint.as_str());
268 let short = digest.chars().take(12).collect::<String>();
269 let stamp = Utc::now().format("%Y%m%dT%H%M%SZ");
270 dir.join(format!("{stamp}-{short}.json"))
271 };
272 let encoded = serde_json::to_string_pretty(receipt)?;
273 fs::write(&path, encoded)?;
274 Ok(path)
275 }
276
277 pub fn read_review_receipt(path: &Path) -> anyhow::Result<ReviewReceipt> {
278 let raw = fs::read_to_string(path)?;
279 Ok(serde_json::from_str(&raw)?)
280 }
281
282 pub fn latest_review_receipt_for_diff(
283 diff: &str,
284 ) -> anyhow::Result<Option<(PathBuf, ReviewReceipt)>> {
285 let dir = codewhale_config::resolve_state_dir("review-receipts")?;
286 if !dir.is_dir() {
287 return Ok(None);
288 }
289
290 let expected = diff_fingerprint(diff);
291 let mut matches = Vec::new();
292 for entry in fs::read_dir(dir)? {
293 let Ok(entry) = entry else {
294 continue;
295 };
296 let path = entry.path();
297 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
298 continue;
299 }
300 let Ok(receipt) = read_review_receipt(&path) else {
301 continue;
302 };
303 if receipt.diff_fingerprint != expected {
304 continue;
305 }
306 let modified = entry.metadata().and_then(|meta| meta.modified()).ok();
307 matches.push((modified, path, receipt));
308 }
309 matches.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
310 Ok(matches.pop().map(|(_, path, receipt)| (path, receipt)))
311 }
312
313 #[must_use]
314 pub fn validate_review_receipt_for_diff(
315 diff: &str,
316 receipt: &ReviewReceipt,
317 receipt_path: Option<PathBuf>,
318 ) -> ReviewReceiptValidation {
319 let expected = diff_fingerprint(diff);
320 let mut validation = ReviewReceiptValidation {
321 passed: false,
322 reason: String::new(),
323 diff_fingerprint: expected.clone(),
324 receipt_fingerprint: Some(receipt.diff_fingerprint.clone()),
325 receipt_path,
326 unresolved_risk: Some(receipt.unresolved_risk.clone()),
327 };
328
329 if receipt.schema_version != REVIEW_RECEIPT_SCHEMA_VERSION {
330 validation.reason = format!(
331 "unsupported review receipt schema version {}",
332 receipt.schema_version
333 );
334 return validation;
335 }
336 if receipt.diff_fingerprint != expected {
337 validation.reason = "current diff fingerprint does not match receipt".to_string();
338 return validation;
339 }
340 if receipt.unresolved_risk.unresolved {
341 validation.reason = receipt.unresolved_risk.summary.clone();
342 return validation;
343 }
344 if let Some(check) = receipt
345 .checks_run
346 .iter()
347 .find(|check| !review_receipt_check_status_passes(&check.status))
348 {
349 validation.reason = format!(
350 "review receipt check '{}' did not pass: {}",
351 check.name, check.status
352 );
353 return validation;
354 }
355
356 validation.passed = true;
357 validation.reason = "receipt matches current diff and has no unresolved risk".to_string();
358 validation
359 }
360
361 #[must_use]
362 pub fn diff_fingerprint(diff: &str) -> String {
363 format!("sha256:{}", sha256_hex(diff.as_bytes()))
364 }
365
366 fn parse_review_output_json(raw: &str) -> Option<ReviewOutput> {
367 if let Ok(parsed) = serde_json::from_str::<ReviewOutput>(raw) {
368 return Some(parsed);
369 }
370
371 let Value::String(inner) = serde_json::from_str::<Value>(raw).ok()? else {
372 return None;
373 };
374 if inner.trim().is_empty() || inner == raw {
375 return None;
376 }
377 parse_review_output_json(&inner)
378 }
379
380 fn highest_review_severity(output: &ReviewOutput) -> String {
381 let mut highest = "none";
382 for issue in &output.issues {
383 let severity = issue.severity.as_str();
384 if severity_rank(severity) > severity_rank(highest) {
385 highest = severity;
386 }
387 }
388 highest.to_string()
389 }
390
391 fn severity_rank(severity: &str) -> u8 {
392 match severity {
393 "error" => 4,
394 "warning" => 3,
395 "info" => 2,
396 "none" => 1,
397 _ => 0,
398 }
399 }
400
401 fn review_receipt_check_status_passes(status: &str) -> bool {
402 matches!(
403 status.trim().to_ascii_lowercase().as_str(),
404 "passed" | "pass" | "success" | "ok"
405 )
406 }
407
408 fn sha256_hex(bytes: &[u8]) -> String {
409 crate::hashing::sha256_hex(bytes)
410 }
411
412 pub struct ReviewTool {
413 client: Option<DeepSeekClient>,
414 model: String,
415 }
416
417 impl ReviewTool {
418 #[must_use]
419 pub fn new(client: Option<DeepSeekClient>, model: String) -> Self {
420 Self { client, model }
421 }
422 }
423
424 #[async_trait]
425 impl ToolSpec for ReviewTool {
426 fn name(&self) -> &'static str {
427 "review"
428 }
429
430 fn description(&self) -> &'static str {
431 "Run a structured code review for a file, git diff, or GitHub pull request."
432 }
433
434 fn input_schema(&self) -> Value {
435 json!({
436 "type": "object",
437 "properties": {
438 "target": {
439 "type": "string",
440 "description": "File path, PR URL, or the literal 'diff'/'staged' for git diff review."
441 },
442 "kind": {
443 "type": "string",
444 "description": "Optional explicit target type: file, diff, or pr."
445 },
446 "base": {
447 "type": "string",
448 "description": "Optional git base ref when using diff target (e.g. origin/main)."
449 },
450 "staged": {
451 "type": "boolean",
452 "description": "Review staged changes when using diff target (default: false)."
453 },
454 "max_chars": {
455 "type": "integer",
456 "description": "Maximum characters to include from the source (default: 200000)."
457 }
458 },
459 "required": ["target"]
460 })
461 }
462
463 fn capabilities(&self) -> Vec<ToolCapability> {
464 vec![ToolCapability::ReadOnly, ToolCapability::Network]
465 }
466
467 fn approval_requirement(&self) -> ApprovalRequirement {
468 ApprovalRequirement::Auto
469 }
470
471 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
472 let Some(client) = self.client.clone() else {
473 return Err(ToolError::not_available(
474 "Review tool requires an active DeepSeek client".to_string(),
475 ));
476 };
477
478 let target = required_str(&input, "target")?.trim();
479 if target.is_empty() {
480 return Err(ToolError::invalid_input("target cannot be empty"));
481 }
482
483 let kind = optional_str(&input, "kind")?.map(|s| s.trim().to_ascii_lowercase());
484 let base = optional_str(&input, "base")?.map(|s| s.trim().to_string());
485 let staged = optional_bool(&input, "staged", false)?;
486 let max_chars =
487 usize::try_from(optional_u64(&input, "max_chars", DEFAULT_MAX_CHARS as u64)?)
488 .unwrap_or(DEFAULT_MAX_CHARS)
489 .clamp(1, MAX_MAX_CHARS);
490
491 let source =
492 resolve_review_source(target, kind.as_deref(), staged, base.as_deref(), context)
493 .await?;
494 let prompt = build_review_prompt(&source, max_chars);
495
496 let request = MessageRequest {
497 model: self.model.clone(),
498 messages: vec![Message {
499 role: "user".to_string(),
500 content: vec![ContentBlock::Text {
501 text: prompt,
502 cache_control: None,
503 }],
504 }],
505 max_tokens: REVIEW_MAX_TOKENS,
506 system: Some(SystemPrompt::Text(REVIEW_SYSTEM_PROMPT.to_string())),
507 tools: None,
508 tool_choice: None,
509 metadata: None,
510 thinking: None,
511 reasoning_effort: None,
512 stream: Some(false),
513 temperature: Some(0.2),
514 top_p: Some(0.9),
515 };
516
517 let route = client.effective_route_envelope(&request.model, chrono::Utc::now());
518 let response = client
519 .create_message(request)
520 .await
521 .map_err(|e| ToolError::execution_failed(format!("Review request failed: {e}")))?;
522
523 let response_text = extract_text(&response.content);
524 let output = ReviewOutput::from_str(&response_text);
525 let metadata = review_usage_metadata(&route, &response.usage);
526 let result =
527 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))?;
528 Ok(result.with_metadata(metadata))
529 }
530 }
531
532 fn review_usage_metadata(
533 route: &crate::cost_status::EffectiveRouteEnvelope,
534 usage: &Usage,
535 ) -> Value {
536 let mut metadata = json!({
537 "tool": "review",
538 "input_tokens": usage.input_tokens,
539 "output_tokens": usage.output_tokens,
540 });
541 // Every billable class, from the one shared producer, so a child turn can be
542 // priced with the same completeness as a parent turn (#4318).
543 crate::cost_status::attach_child_usage_metadata(&mut metadata, route, usage);
544 metadata
545 }
546
547 enum ReviewSource {
548 File { display: String, content: String },
549 Diff { label: String, diff: String },
550 PullRequest { label: String, diff: String },
551 }
552
553 async fn resolve_review_source(
554 target: &str,
555 kind: Option<&str>,
556 staged: bool,
557 base: Option<&str>,
558 context: &ToolContext,
559 ) -> Result<ReviewSource, ToolError> {
560 if let Some(kind) = kind {
561 return match kind {
562 "file" => resolve_file_target(target, context),
563 "diff" => {
564 let diff = resolve_diff_target(context.workspace.as_path(), staged, base).await?;
565 Ok(ReviewSource::Diff {
566 label: "git diff".to_string(),
567 diff,
568 })
569 }
570 "pr" | "pull" | "pull_request" => {
571 let pr = parse_pr_url(target)
572 .ok_or_else(|| ToolError::invalid_input("Invalid pull request URL"))?;
573 let diff = gh_pr_diff(&pr, &context.workspace).await?;
574 Ok(ReviewSource::PullRequest {
575 label: pr.label(),
576 diff,
577 })
578 }
579 other => Err(ToolError::invalid_input(format!(
580 "Unknown review kind '{other}'"
581 ))),
582 };
583 }
584
585 if let Some(pr) = parse_pr_url(target) {
586 let diff = gh_pr_diff(&pr, &context.workspace).await?;
587 return Ok(ReviewSource::PullRequest {
588 label: pr.label(),
589 diff,
590 });
591 }
592
593 if let Some(staged_override) = diff_mode_from_target(target) {
594 let staged = staged || staged_override;
595 let diff = resolve_diff_target(context.workspace.as_path(), staged, base).await?;
596 return Ok(ReviewSource::Diff {
597 label: if staged {
598 "git diff --cached"
599 } else {
600 "git diff"
601 }
602 .to_string(),
603 diff,
604 });
605 }
606
607 resolve_file_target(target, context)
608 }
609
610 fn resolve_file_target(target: &str, context: &ToolContext) -> Result<ReviewSource, ToolError> {
611 let path = context.resolve_path(target)?;
612 if !path.is_file() {
613 return Err(ToolError::invalid_input(format!(
614 "Target is not a file: {}",
615 path.display()
616 )));
617 }
618 let content = fs::read_to_string(&path).map_err(|e| {
619 ToolError::execution_failed(format!("Failed to read file {}: {e}", path.display()))
620 })?;
621 let display = path
622 .strip_prefix(&context.workspace)
623 .unwrap_or(&path)
624 .to_string_lossy()
625 .to_string();
626 Ok(ReviewSource::File { display, content })
627 }
628
629 async fn resolve_diff_target(
630 workspace: &Path,
631 staged: bool,
632 base: Option<&str>,
633 ) -> Result<String, ToolError> {
634 let base = base.map(str::trim).filter(|base| !base.is_empty());
635 let base_commit = if let Some(base) = base {
636 // Resolve the user-supplied ref before placing it in `git diff`. This
637 // both rejects option-looking input and gives the staged path a real
638 // commit from which it can compute the merge base.
639 let revision = format!("{base}^{{commit}}");
640 let output = run_review_git(
641 workspace,
642 vec![
643 "rev-parse".to_string(),
644 "--verify".to_string(),
645 "--end-of-options".to_string(),
646 revision,
647 ],
648 "resolve review base",
649 )
650 .await?;
651 if !output.status.success() {
652 let stderr = String::from_utf8_lossy(&output.stderr);
653 return Err(ToolError::invalid_input(format!(
654 "Invalid git base ref '{base}': {}",
655 stderr.trim()
656 )));
657 }
658 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
659 if commit.is_empty() || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) {
660 return Err(ToolError::execution_failed(format!(
661 "git resolved base ref '{base}' to an invalid commit id"
662 )));
663 }
664 Some(commit)
665 } else {
666 None
667 };
668
669 let mut args = vec!["diff".to_string()];
670 if staged {
671 args.push("--cached".to_string());
672 if let Some(base_commit) = base_commit {
673 // `git diff --cached <base>...HEAD` is invalid because the index
674 // is already one side of this diff. Preserve triple-dot semantics
675 // by resolving the merge base first, then compare that tree with
676 // the index (committed branch work plus the staged snapshot).
677 let output = run_review_git(
678 workspace,
679 vec!["merge-base".to_string(), base_commit, "HEAD".to_string()],
680 "resolve staged review merge base",
681 )
682 .await?;
683 if !output.status.success() {
684 let stderr = String::from_utf8_lossy(&output.stderr);
685 return Err(ToolError::execution_failed(format!(
686 "git merge-base failed: {}",
687 stderr.trim()
688 )));
689 }
690 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
691 if merge_base.is_empty() || !merge_base.bytes().all(|byte| byte.is_ascii_hexdigit()) {
692 return Err(ToolError::execution_failed(
693 "git merge-base returned an invalid commit id",
694 ));
695 }
696 args.push(merge_base);
697 }
698 } else if let Some(base_commit) = base_commit {
699 args.push(format!("{base_commit}...HEAD"));
700 }
701 args.push("--".to_string());
702
703 let output = run_review_git(workspace, args, "generate review diff").await?;
704 if !output.status.success() {
705 let stderr = String::from_utf8_lossy(&output.stderr);
706 return Err(ToolError::execution_failed(format!(
707 "git diff failed: {}",
708 stderr.trim()
709 )));
710 }
711 let diff = String::from_utf8_lossy(&output.stdout).to_string();
712 if diff.trim().is_empty() {
713 return Err(ToolError::invalid_input("No diff to review"));
714 }
715 Ok(diff)
716 }
717
718 async fn run_review_git(
719 workspace: &Path,
720 args: Vec<String>,
721 operation: &'static str,
722 ) -> Result<std::process::Output, ToolError> {
723 let workspace = workspace.to_path_buf();
724 tokio::task::spawn_blocking(move || {
725 let Some(mut cmd) = crate::dependencies::Git::command() else {
726 return Err(ToolError::execution_failed("git not found"));
727 };
728 cmd.args(args).current_dir(workspace).output().map_err(|e| {
729 ToolError::execution_failed(format!("Failed to {operation} with git: {e}"))
730 })
731 })
732 .await
733 .map_err(|e| ToolError::execution_failed(format!("git {operation} task panicked: {e}")))?
734 }
735
736 async fn gh_pr_diff(pr: &PullRequestRef, workspace: &Path) -> Result<String, ToolError> {
737 let Some(mut cmd) = crate::dependencies::Gh::command() else {
738 return Err(ToolError::execution_failed("gh not found"));
739 };
740 cmd.arg("pr")
741 .arg("diff")
742 .arg(&pr.number)
743 .arg("--repo")
744 .arg(format!("{}/{}", pr.owner, pr.repo))
745 .current_dir(workspace);
746
747 let output = tokio::task::spawn_blocking(move || cmd.output())
748 .await
749 .map_err(|e| ToolError::execution_failed(format!("gh pr diff task panicked: {e}")))?
750 .map_err(|e| {
751 ToolError::execution_failed(format!("Failed to run gh pr diff (is gh installed?): {e}"))
752 })?;
753 if !output.status.success() {
754 let stderr = String::from_utf8_lossy(&output.stderr);
755 return Err(ToolError::execution_failed(format!(
756 "gh pr diff failed: {}",
757 stderr.trim()
758 )));
759 }
760 let diff = String::from_utf8_lossy(&output.stdout).to_string();
761 if diff.trim().is_empty() {
762 return Err(ToolError::invalid_input("Pull request diff is empty."));
763 }
764 Ok(diff)
765 }
766
767 fn build_review_prompt(source: &ReviewSource, max_chars: usize) -> String {
768 match source {
769 ReviewSource::File {
770 display, content, ..
771 } => {
772 let numbered = format_with_line_numbers(content);
773 let truncated = truncate_with_ellipsis(&numbered, max_chars, "\n...[truncated]\n");
774 format!(
775 "Review the following file and provide feedback.\n\
776 Path: {display}\n\n{truncated}\n\nEnd of file."
777 )
778 }
779 ReviewSource::Diff { label, diff } => {
780 let truncated = truncate_with_ellipsis(diff, max_chars, "\n...[truncated]\n");
781 format!(
782 "Review the following {label} and provide feedback.\n\n{truncated}\n\nEnd of diff."
783 )
784 }
785 ReviewSource::PullRequest { label, diff } => {
786 let truncated = truncate_with_ellipsis(diff, max_chars, "\n...[truncated]\n");
787 format!(
788 "Review the following pull request diff ({label}) and provide feedback.\n\n{truncated}\n\nEnd of diff."
789 )
790 }
791 }
792 }
793
794 fn format_with_line_numbers(content: &str) -> String {
795 content
796 .lines()
797 .enumerate()
798 .map(|(idx, line)| format!("{:>4} | {}", idx + 1, line))
799 .collect::<Vec<_>>()
800 .join("\n")
801 }
802
803 fn extract_text(blocks: &[ContentBlock]) -> String {
804 let mut output = String::new();
805 for block in blocks {
806 if let ContentBlock::Text { text, .. } = block {
807 if !output.is_empty() {
808 output.push('\n');
809 }
810 output.push_str(text);
811 }
812 }
813 output.trim().to_string()
814 }
815
816 fn normalize_optional(value: Option<String>) -> Option<String> {
817 value
818 .map(|v| v.trim().to_string())
819 .filter(|v| !v.is_empty())
820 }
821
822 fn normalize_severity(value: &str) -> String {
823 let lower = value.trim().to_ascii_lowercase();
824 if lower.starts_with("err") || lower == "critical" || lower == "high" {
825 "error".to_string()
826 } else if lower.starts_with("warn") || lower == "medium" {
827 "warning".to_string()
828 } else {
829 "info".to_string()
830 }
831 }
832
833 fn extract_json_block(raw: &str) -> Option<&str> {
834 let start = raw.find('{')?;
835 let end = raw.rfind('}')?;
836 if end <= start {
837 None
838 } else {
839 Some(&raw[start..=end])
840 }
841 }
842
843 fn diff_mode_from_target(target: &str) -> Option<bool> {
844 match target.trim().to_ascii_lowercase().as_str() {
845 "diff" | "git diff" | "changes" | "working tree" | "working-tree" => Some(false),
846 "staged" | "cached" | "git diff --cached" | "git diff --staged" => Some(true),
847 _ => None,
848 }
849 }
850
851 #[derive(Debug, Clone)]
852 struct PullRequestRef {
853 owner: String,
854 repo: String,
855 number: String,
856 }
857
858 impl PullRequestRef {
859 fn label(&self) -> String {
860 format!("{}/{}#{}", self.owner, self.repo, self.number)
861 }
862 }
863
864 fn parse_pr_url(url: &str) -> Option<PullRequestRef> {
865 let trimmed = url.trim().trim_end_matches('/');
866 if !trimmed.starts_with("http") {
867 return None;
868 }
869 let parts: Vec<&str> = trimmed.split('/').collect();
870 let pull_idx = parts.iter().position(|part| *part == "pull")?;
871 if pull_idx < 2 || pull_idx + 1 >= parts.len() {
872 return None;
873 }
874 let owner = parts.get(pull_idx.saturating_sub(2))?;
875 let repo = parts.get(pull_idx.saturating_sub(1))?;
876 let number = parts.get(pull_idx + 1)?;
877 if owner.is_empty() || repo.is_empty() || number.is_empty() {
878 return None;
879 }
880 Some(PullRequestRef {
881 owner: (*owner).to_string(),
882 repo: (*repo).to_string(),
883 number: (*number).to_string(),
884 })
885 }
886
887 #[cfg(test)]
888 mod tests {
889 use super::*;
890
891 fn fixture_git(workspace: &Path, args: &[&str]) -> std::process::Output {
892 let mut command = crate::dependencies::Git::command().expect("git test dependency");
893 let output = command
894 .args(args)
895 .current_dir(workspace)
896 .output()
897 .expect("run git fixture command");
898 assert!(
899 output.status.success(),
900 "git {} failed: {}",
901 args.join(" "),
902 String::from_utf8_lossy(&output.stderr)
903 );
904 output
905 }
906
907 #[tokio::test]
908 async fn staged_diff_with_base_compares_merge_base_to_index() {
909 let repo = tempfile::TempDir::new().expect("temp git repository");
910 fixture_git(repo.path(), &["init"]);
911 fixture_git(repo.path(), &["config", "user.name", "Codewhale Test"]);
912 fixture_git(
913 repo.path(),
914 &["config", "user.email", "codewhale-test@example.invalid"],
915 );
916
917 let tracked = repo.path().join("tracked.txt");
918 fs::write(&tracked, "base\n").expect("write base fixture");
919 fixture_git(repo.path(), &["add", "tracked.txt"]);
920 fixture_git(repo.path(), &["commit", "-m", "base"]);
921 let base =
922 String::from_utf8_lossy(&fixture_git(repo.path(), &["rev-parse", "HEAD"]).stdout)
923 .trim()
924 .to_string();
925
926 fs::write(&tracked, "base\ncommitted\n").expect("write committed fixture");
927 fixture_git(repo.path(), &["add", "tracked.txt"]);
928 fixture_git(repo.path(), &["commit", "-m", "branch change"]);
929 fs::write(&tracked, "base\ncommitted\nstaged\n").expect("write staged fixture");
930 fixture_git(repo.path(), &["add", "tracked.txt"]);
931 fs::write(&tracked, "base\ncommitted\nstaged\nunstaged\n").expect("write unstaged fixture");
932
933 let diff = resolve_diff_target(repo.path(), true, Some(&base))
934 .await
935 .expect("staged review diff from base");
936 assert!(diff.contains("+committed"), "{diff}");
937 assert!(diff.contains("+staged"), "{diff}");
938 assert!(!diff.contains("unstaged"), "{diff}");
939 }
940
941 #[test]
942 fn parses_pr_url() {
943 let pr =
944 parse_pr_url("https://github.com/deepseek-ai/deepseek-cli/pull/123").expect("parse pr");
945 assert_eq!(pr.owner, "deepseek-ai");
946 assert_eq!(pr.repo, "deepseek-cli");
947 assert_eq!(pr.number, "123");
948 }
949
950 #[test]
951 fn ignores_non_pr_url() {
952 assert!(parse_pr_url("https://github.com/deepseek-ai/deepseek-cli").is_none());
953 assert!(parse_pr_url("not-a-url").is_none());
954 }
955
956 #[test]
957 fn extracts_json_block() {
958 let raw = "prefix {\"summary\":\"ok\"} suffix";
959 let block = extract_json_block(raw).expect("block");
960 assert!(block.contains("\"summary\""));
961 }
962
963 #[test]
964 fn review_output_parses_structured_json() {
965 let raw = r#"{
966 "summary": " Looks good overall ",
967 "issues": [{
968 "severity": "high",
969 "title": " Missing test ",
970 "description": " Add coverage ",
971 "path": " src/lib.rs ",
972 "line": 42
973 }],
974 "suggestions": [{
975 "path": "",
976 "line": 7,
977 "suggestion": " Keep the helper small "
978 }],
979 "overall_assessment": " Safe after test "
980 }"#;
981
982 let output = ReviewOutput::from_str(raw);
983
984 assert_eq!(output.summary, "Looks good overall");
985 assert_eq!(output.issues.len(), 1);
986 assert_eq!(output.issues[0].severity, "error");
987 assert_eq!(output.issues[0].title, "Missing test");
988 assert_eq!(output.issues[0].path.as_deref(), Some("src/lib.rs"));
989 assert_eq!(output.issues[0].line, Some(42));
990 assert_eq!(output.suggestions.len(), 1);
991 assert_eq!(output.suggestions[0].path, None);
992 assert_eq!(output.suggestions[0].line, Some(7));
993 assert_eq!(output.suggestions[0].suggestion, "Keep the helper small");
994 assert_eq!(output.overall_assessment, "Safe after test");
995 }
996
997 #[test]
998 fn review_output_parses_double_encoded_json_string() {
999 let inner = serde_json::json!({
1000 "summary": "structured",
1001 "issues": [{
1002 "severity": "warning",
1003 "title": "Risk",
1004 "description": "The parser should not fall back to a raw JSON string.",
1005 "path": "src/main.rs",
1006 "line": 3
1007 }],
1008 "suggestions": [],
1009 "overall_assessment": "usable"
1010 })
1011 .to_string();
1012 let double_encoded = serde_json::to_string(&inner).expect("encode string");
1013
1014 let output = ReviewOutput::from_str(&double_encoded);
1015
1016 assert_eq!(output.summary, "structured");
1017 assert_eq!(output.issues.len(), 1);
1018 assert_eq!(output.issues[0].severity, "warning");
1019 assert_eq!(output.issues[0].path.as_deref(), Some("src/main.rs"));
1020 assert_eq!(output.overall_assessment, "usable");
1021 }
1022
1023 #[test]
1024 fn review_output_fallback_keeps_summary() {
1025 let output = ReviewOutput::from_str("Not JSON");
1026 assert!(!output.summary.is_empty());
1027 assert!(output.issues.is_empty());
1028 }
1029
1030 #[test]
1031 fn review_usage_metadata_reports_child_tokens_for_cost_accrual() {
1032 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1033 None,
1034 crate::config::ApiProvider::Deepseek,
1035 "deepseek",
1036 "deepseek-v4-flash",
1037 Some("https://api.deepseek.com/v1"),
1038 chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).expect("epoch"),
1039 );
1040 let metadata = review_usage_metadata(
1041 &route,
1042 &Usage {
1043 input_tokens: 123,
1044 output_tokens: 45,
1045 prompt_cache_hit_tokens: Some(100),
1046 prompt_cache_miss_tokens: Some(23),
1047 reasoning_tokens: Some(7),
1048 ..Default::default()
1049 },
1050 );
1051
1052 assert_eq!(metadata["tool"], "review");
1053 assert_eq!(metadata["child_model"], "deepseek-v4-flash");
1054 assert_eq!(metadata["child_input_tokens"], 123);
1055 assert_eq!(metadata["child_output_tokens"], 45);
1056 assert_eq!(metadata["child_prompt_cache_hit_tokens"], 100);
1057 assert_eq!(metadata["child_prompt_cache_miss_tokens"], 23);
1058 assert_eq!(metadata["child_reasoning_tokens"], 7);
1059 }
1060
1061 #[test]
1062 fn pre_push_diff_review_receipt_includes_fingerprint_and_risk() {
1063 let diff = "diff --git a/src/lib.rs b/src/lib.rs\n+let risky = true;\n";
1064 let output = ReviewOutput {
1065 summary: "Found one issue".to_string(),
1066 issues: vec![ReviewIssue {
1067 severity: "warning".to_string(),
1068 title: "Missing test".to_string(),
1069 description: "Add coverage".to_string(),
1070 path: Some("src/lib.rs".to_string()),
1071 line: Some(12),
1072 }],
1073 suggestions: vec![ReviewSuggestion {
1074 path: Some("src/lib.rs".to_string()),
1075 line: Some(12),
1076 suggestion: "Add a regression test".to_string(),
1077 }],
1078 overall_assessment: "Needs a test".to_string(),
1079 };
1080
1081 let receipt = build_review_receipt(
1082 "working-tree",
1083 diff,
1084 "deepseek",
1085 "deepseek-v4-pro",
1086 &output,
1087 "review body",
1088 vec![ReviewReceiptCheck {
1089 name: "cargo test -p codewhale-tui".to_string(),
1090 status: "passed".to_string(),
1091 }],
1092 );
1093
1094 assert_eq!(receipt.schema_version, REVIEW_RECEIPT_SCHEMA_VERSION);
1095 assert_eq!(receipt.mode, "pre_push_review");
1096 assert_eq!(receipt.target, "working-tree");
1097 assert_eq!(receipt.diff_fingerprint, diff_fingerprint(diff));
1098 assert_eq!(receipt.diff_lines, 2);
1099 assert_eq!(receipt.provider, "deepseek");
1100 assert_eq!(receipt.model, "deepseek-v4-pro");
1101 assert_eq!(receipt.checks_run.len(), 1);
1102 assert_eq!(receipt.findings.issue_count, 1);
1103 assert_eq!(receipt.findings.suggestion_count, 1);
1104 assert_eq!(receipt.findings.highest_severity, "warning");
1105 assert!(receipt.unresolved_risk.unresolved);
1106 assert_eq!(receipt.unresolved_risk.level, "warning");
1107 assert_eq!(
1108 receipt.review_content_sha256,
1109 sha256_hex("review body".as_bytes())
1110 );
1111 }
1112
1113 #[test]
1114 fn write_review_receipt_accepts_override_path() {
1115 let dir = tempfile::tempdir().expect("tempdir");
1116 let path = dir.path().join("nested").join("receipt.json");
1117 let output = ReviewOutput::from_str("Looks good");
1118 let receipt = build_review_receipt(
1119 "staged",
1120 "diff --git a/a b/a\n",
1121 "deepseek",
1122 "deepseek-v4-flash",
1123 &output,
1124 "Looks good",
1125 Vec::new(),
1126 );
1127
1128 let written = write_review_receipt(&receipt, Some(&path)).expect("write receipt");
1129
1130 assert_eq!(written, path);
1131 let raw = fs::read_to_string(&written).expect("read receipt");
1132 let decoded: ReviewReceipt = serde_json::from_str(&raw).expect("decode receipt");
1133 assert_eq!(decoded.diff_fingerprint, receipt.diff_fingerprint);
1134 assert_eq!(decoded.unresolved_risk.level, "none");
1135 }
1136
1137 #[test]
1138 fn review_receipt_validation_passes_matching_clean_receipt() {
1139 let diff = "diff --git a/a b/a\n+ok\n";
1140 let output = ReviewOutput::from_str("Looks good");
1141 let receipt = build_review_receipt(
1142 "working-tree",
1143 diff,
1144 "deepseek",
1145 "deepseek-v4-flash",
1146 &output,
1147 "Looks good",
1148 vec![ReviewReceiptCheck {
1149 name: "cargo test".to_string(),
1150 status: "passed".to_string(),
1151 }],
1152 );
1153
1154 let validation = validate_review_receipt_for_diff(diff, &receipt, None);
1155
1156 assert!(validation.passed);
1157 assert_eq!(validation.diff_fingerprint, diff_fingerprint(diff));
1158 assert_eq!(
1159 validation.reason,
1160 "receipt matches current diff and has no unresolved risk"
1161 );
1162 }
1163
1164 #[test]
1165 fn review_receipt_validation_rejects_changed_diff() {
1166 let output = ReviewOutput::from_str("Looks good");
1167 let receipt = build_review_receipt(
1168 "working-tree",
1169 "diff --git a/a b/a\n+old\n",
1170 "deepseek",
1171 "deepseek-v4-flash",
1172 &output,
1173 "Looks good",
1174 Vec::new(),
1175 );
1176
1177 let validation =
1178 validate_review_receipt_for_diff("diff --git a/a b/a\n+new\n", &receipt, None);
1179
1180 assert!(!validation.passed);
1181 assert_eq!(
1182 validation.reason,
1183 "current diff fingerprint does not match receipt"
1184 );
1185 }
1186
1187 #[test]
1188 fn review_receipt_validation_rejects_unresolved_risk() {
1189 let diff = "diff --git a/a b/a\n+risk\n";
1190 let output = ReviewOutput {
1191 summary: "Risk found".to_string(),
1192 issues: vec![ReviewIssue {
1193 severity: "error".to_string(),
1194 title: "Unsafe change".to_string(),
1195 description: "Needs work".to_string(),
1196 path: Some("a".to_string()),
1197 line: Some(1),
1198 }],
1199 suggestions: Vec::new(),
1200 overall_assessment: String::new(),
1201 };
1202 let receipt = build_review_receipt(
1203 "working-tree",
1204 diff,
1205 "deepseek",
1206 "deepseek-v4-flash",
1207 &output,
1208 "Risk found",
1209 Vec::new(),
1210 );
1211
1212 let validation = validate_review_receipt_for_diff(diff, &receipt, None);
1213
1214 assert!(!validation.passed);
1215 assert_eq!(validation.unresolved_risk.as_ref().unwrap().level, "error");
1216 assert!(validation.reason.contains("unresolved review issue"));
1217 }
1218
1219 #[test]
1220 fn review_receipt_validation_rejects_failed_check() {
1221 let diff = "diff --git a/a b/a\n+ok\n";
1222 let output = ReviewOutput::from_str("Looks good");
1223 let receipt = build_review_receipt(
1224 "working-tree",
1225 diff,
1226 "deepseek",
1227 "deepseek-v4-flash",
1228 &output,
1229 "Looks good",
1230 vec![ReviewReceiptCheck {
1231 name: "cargo test".to_string(),
1232 status: "failed".to_string(),
1233 }],
1234 );
1235
1236 let validation = validate_review_receipt_for_diff(diff, &receipt, None);
1237
1238 assert!(!validation.passed);
1239 assert!(
1240 validation
1241 .reason
1242 .contains("review receipt check 'cargo test' did not pass")
1243 );
1244 }
1245
1246 #[test]
1247 fn review_receipt_validation_rejects_attached_not_run_check() {
1248 let diff = "diff --git a/a b/a\n+ok\n";
1249 let output = ReviewOutput::from_str("Looks good");
1250 let receipt = build_review_receipt(
1251 "working-tree",
1252 diff,
1253 "deepseek",
1254 "deepseek-v4-flash",
1255 &output,
1256 "Looks good",
1257 vec![ReviewReceiptCheck {
1258 name: "cargo test".to_string(),
1259 status: "not_run".to_string(),
1260 }],
1261 );
1262
1263 let validation = validate_review_receipt_for_diff(diff, &receipt, None);
1264
1265 assert!(!validation.passed);
1266 assert!(
1267 validation
1268 .reason
1269 .contains("review receipt check 'cargo test' did not pass: not_run")
1270 );
1271 }
1272 }
1273
1273 lines RUST