返回 CodeWhale
file_mutation.rs
根目录 / crates / tui / src / tui / history / file_mutation.rs
1 //! Structured, success-only File mutation receipts.
2 //!
3 //! Tool execution owns the exact before/after evidence. This module shapes
4 //! that evidence for the calm transcript without depending on whether an
5 //! approval modal happened to run.
6
7 use std::path::{Component, Path};
8
9 use ratatui::style::{Modifier, Style};
10 use ratatui::text::{Line, Span};
11 use serde_json::Value;
12
13 use crate::palette;
14 use crate::settings::InlineDiffMode;
15 use crate::tools::spec::ToolResult;
16 use crate::tui::diff_render;
17
18 use super::details_affordance_line;
19
20 const MAX_INLINE_DIFF_LINES: usize = 14;
21 const MAX_SUMMARY_CHARS: usize = 180;
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 pub enum FileMutationOutcome {
25 Created,
26 Updated,
27 Deleted,
28 Renamed,
29 }
30
31 #[derive(Debug, Clone, PartialEq, Eq)]
32 pub struct FileMutationFile {
33 pub path: String,
34 pub previous_path: Option<String>,
35 pub outcome: FileMutationOutcome,
36 }
37
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 pub struct FileMutationReceipt {
40 /// Raw execution-owned evidence exposed only through the explicit detail
41 /// route. It is never painted into the ambient Work/transcript surface.
42 pub exact_diff: String,
43 /// Header-redacted copy safe for inline presentation.
44 pub display_diff: String,
45 pub files: Vec<FileMutationFile>,
46 pub added: usize,
47 pub deleted: usize,
48 }
49
50 impl FileMutationReceipt {
51 /// Build a receipt only from an authoritative successful tool result.
52 /// Failed/cancelled calls never carry a success diff into the transcript.
53 #[must_use]
54 pub fn from_success(workspace: &Path, result: &ToolResult) -> Option<Self> {
55 if !result.success {
56 return None;
57 }
58 let mutation = result.metadata.as_ref()?.get("mutation")?;
59 let raw_diff = mutation.get("diff")?.as_str().unwrap_or("");
60 let exact_diff = raw_diff.to_string();
61 let display_diff = redact_diff_headers(workspace, raw_diff);
62 let mut files = Vec::new();
63
64 if let Some(entries) = mutation.get("files").and_then(Value::as_array) {
65 for entry in entries {
66 let Some(raw_path) = entry.get("path").and_then(Value::as_str) else {
67 continue;
68 };
69 let outcome = match entry.get("outcome").and_then(Value::as_str) {
70 Some("created") => FileMutationOutcome::Created,
71 Some("updated") => FileMutationOutcome::Updated,
72 Some("deleted") => FileMutationOutcome::Deleted,
73 _ => continue,
74 };
75 files.push(FileMutationFile {
76 path: privacy_safe_path(workspace, raw_path),
77 previous_path: None,
78 outcome,
79 });
80 }
81 }
82 if let Some(renames) = mutation.get("renames").and_then(Value::as_array) {
83 for rename in renames {
84 let Some(from) = rename.get("from").and_then(Value::as_str) else {
85 continue;
86 };
87 let Some(to) = rename.get("to").and_then(Value::as_str) else {
88 continue;
89 };
90 files.push(FileMutationFile {
91 path: privacy_safe_path(workspace, to),
92 previous_path: Some(privacy_safe_path(workspace, from)),
93 outcome: FileMutationOutcome::Renamed,
94 });
95 }
96 }
97
98 let summaries = diff_render::summarize_diff(&display_diff);
99 let added = summaries.iter().map(|summary| summary.added).sum();
100 let deleted = summaries.iter().map(|summary| summary.deleted).sum();
101 if files.is_empty() && exact_diff.trim().is_empty() {
102 return None;
103 }
104 Some(Self {
105 exact_diff,
106 display_diff,
107 files,
108 added,
109 deleted,
110 })
111 }
112
113 #[must_use]
114 pub fn outcome_label(&self) -> String {
115 let created = self.count(FileMutationOutcome::Created);
116 let updated = self.count(FileMutationOutcome::Updated);
117 let deleted = self.count(FileMutationOutcome::Deleted);
118 let renamed = self.count(FileMutationOutcome::Renamed);
119 let mut outcome_parts = Vec::new();
120 push_count(&mut outcome_parts, created, "created");
121 push_count(&mut outcome_parts, updated, "updated");
122 push_count(&mut outcome_parts, deleted, "deleted");
123 push_count(&mut outcome_parts, renamed, "renamed");
124
125 if self.files.is_empty() {
126 "Changed files".to_string()
127 } else if self.files.len() == 1 {
128 let file = &self.files[0];
129 match file.outcome {
130 FileMutationOutcome::Created => format!("Created {}", file.path),
131 FileMutationOutcome::Updated => format!("Updated {}", file.path),
132 FileMutationOutcome::Deleted => format!("Deleted {}", file.path),
133 FileMutationOutcome::Renamed => format!(
134 "Renamed {} → {}",
135 file.previous_path.as_deref().unwrap_or("file"),
136 file.path
137 ),
138 }
139 } else {
140 format!("{} files · {}", self.files.len(), outcome_parts.join(" · "))
141 }
142 }
143
144 #[must_use]
145 pub fn semantic_summary(&self) -> String {
146 let stats = format!("+{} -{}", self.added, self.deleted);
147 let separator_chars = " · ".chars().count();
148 let outcome_budget = MAX_SUMMARY_CHARS
149 .saturating_sub(stats.chars().count())
150 .saturating_sub(separator_chars);
151 format!(
152 "{} · {stats}",
153 bounded_text(&self.outcome_label(), outcome_budget)
154 )
155 }
156
157 #[must_use]
158 pub fn inspect_text(&self) -> String {
159 let summary = self.semantic_summary();
160 if self.exact_diff.trim().is_empty() {
161 format!("{summary}\n\n(no textual changes)")
162 } else {
163 format!("{summary}\n\n{}", self.exact_diff)
164 }
165 }
166
167 pub fn render_inline(&self, width: u16, mode: InlineDiffMode) -> Vec<Line<'static>> {
168 match mode {
169 InlineDiffMode::Off => vec![exact_evidence_hint()],
170 InlineDiffMode::Summary => vec![
171 Line::from(Span::styled(
172 self.semantic_summary(),
173 Style::default()
174 .fg(palette::TEXT_PRIMARY)
175 .add_modifier(Modifier::BOLD),
176 )),
177 exact_evidence_hint(),
178 ],
179 InlineDiffMode::Full => {
180 let mut lines = vec![Line::from(Span::styled(
181 self.semantic_summary(),
182 Style::default()
183 .fg(palette::TEXT_PRIMARY)
184 .add_modifier(Modifier::BOLD),
185 ))];
186 if !self.display_diff.trim().is_empty() {
187 let rendered = diff_render::render_diff_body(&self.display_diff, width);
188 let omitted = rendered.len().saturating_sub(MAX_INLINE_DIFF_LINES);
189 lines.extend(rendered.into_iter().take(MAX_INLINE_DIFF_LINES));
190 if omitted > 0 {
191 let detail_hint =
192 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("change");
193 lines.push(details_affordance_line(
194 &format!("+{omitted} diff lines · {detail_hint}"),
195 Style::default().fg(palette::TEXT_MUTED).italic(),
196 ));
197 } else {
198 lines.push(exact_evidence_hint());
199 }
200 } else {
201 lines.push(exact_evidence_hint());
202 }
203 lines
204 }
205 }
206 }
207
208 fn count(&self, outcome: FileMutationOutcome) -> usize {
209 self.files
210 .iter()
211 .filter(|file| file.outcome == outcome)
212 .count()
213 }
214 }
215
216 fn exact_evidence_hint() -> Line<'static> {
217 details_affordance_line(
218 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("change"),
219 Style::default().fg(palette::TEXT_MUTED).italic(),
220 )
221 }
222
223 fn push_count(parts: &mut Vec<String>, count: usize, label: &str) {
224 if count > 0 {
225 parts.push(format!("{count} {label}"));
226 }
227 }
228
229 fn bounded_text(value: &str, max_chars: usize) -> String {
230 if value.chars().count() <= max_chars {
231 return value.to_string();
232 }
233 if max_chars == 0 {
234 return String::new();
235 }
236 let mut bounded = value
237 .chars()
238 .take(max_chars.saturating_sub(1))
239 .collect::<String>();
240 bounded.push('…');
241 bounded
242 }
243
244 fn privacy_safe_path(workspace: &Path, raw: &str) -> String {
245 let normalized = raw.replace('\\', "/");
246 let workspace = workspace.to_string_lossy().replace('\\', "/");
247 let relative = if Path::new(raw).is_absolute() || normalized.starts_with('/') {
248 let prefix = workspace.trim_end_matches('/');
249 if normalized == prefix {
250 ""
251 } else if let Some(relative) = normalized.strip_prefix(&format!("{prefix}/")) {
252 relative
253 } else {
254 return "<external file>".to_string();
255 }
256 } else {
257 normalized.as_str()
258 };
259 let path = Path::new(relative);
260 if path.components().any(|component| {
261 matches!(
262 component,
263 Component::ParentDir | Component::RootDir | Component::Prefix(_)
264 )
265 }) {
266 return "<external file>".to_string();
267 }
268 let display = path.to_string_lossy().replace('\\', "/");
269 if display.is_empty() {
270 "<workspace>".to_string()
271 } else {
272 display
273 }
274 }
275
276 fn redact_diff_headers(workspace: &Path, diff: &str) -> String {
277 let mut redacted = diff
278 .lines()
279 .map(|line| {
280 for prefix in ["--- ", "+++ ", "rename from ", "rename to "] {
281 if let Some(raw) = line.strip_prefix(prefix) {
282 if raw == "/dev/null" {
283 return line.to_string();
284 }
285 let side = if raw.starts_with("a/") {
286 "a/"
287 } else if raw.starts_with("b/") {
288 "b/"
289 } else {
290 ""
291 };
292 let path = raw.strip_prefix(side).unwrap_or(raw);
293 return format!("{prefix}{side}{}", privacy_safe_path(workspace, path));
294 }
295 }
296 if let Some(rest) = line.strip_prefix("diff --git ") {
297 let mut paths = rest.split_whitespace();
298 if let (Some(old), Some(new)) = (paths.next(), paths.next()) {
299 let old = old.strip_prefix("a/").unwrap_or(old);
300 let new = new.strip_prefix("b/").unwrap_or(new);
301 return format!(
302 "diff --git a/{} b/{}",
303 privacy_safe_path(workspace, old),
304 privacy_safe_path(workspace, new)
305 );
306 }
307 }
308 line.to_string()
309 })
310 .collect::<Vec<_>>()
311 .join("\n");
312 if diff.ends_with('\n') {
313 redacted.push('\n');
314 }
315 redacted
316 }
317
318 #[cfg(test)]
319 mod tests {
320 use super::*;
321 use serde_json::json;
322
323 fn plain(lines: &[Line<'_>]) -> String {
324 lines
325 .iter()
326 .map(|line| {
327 line.spans
328 .iter()
329 .map(|span| span.content.as_ref())
330 .collect::<String>()
331 })
332 .collect::<Vec<_>>()
333 .join("\n")
334 }
335
336 fn result(mutation: Value) -> ToolResult {
337 ToolResult::success("ok").with_metadata(json!({ "mutation": mutation }))
338 }
339
340 #[test]
341 fn creates_bounded_semantic_summary_and_redacts_external_headers() {
342 let result = result(json!({
343 "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -0,0 +1 @@\n+secret\n",
344 "files": [{ "path": "/Users/alice/private.rs", "outcome": "created" }],
345 "renames": []
346 }));
347 let receipt =
348 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
349 assert_eq!(receipt.files[0].path, "<external file>");
350 assert!(receipt.exact_diff.contains("alice"));
351 assert!(!receipt.display_diff.contains("alice"));
352 assert!(
353 receipt
354 .semantic_summary()
355 .contains("Created <external file>")
356 );
357 }
358
359 #[test]
360 fn rename_and_multifile_outcomes_stay_semantic() {
361 let result = result(json!({
362 "diff": "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n--- a/lib.rs\n+++ b/lib.rs\n@@ -1 +1 @@\n-old\n+new\n",
363 "files": [{ "path": "lib.rs", "outcome": "updated" }],
364 "renames": [{ "from": "old.rs", "to": "new.rs" }]
365 }));
366 let receipt =
367 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
368 assert_eq!(receipt.files.len(), 2);
369 assert_eq!(receipt.added, 1);
370 assert_eq!(receipt.deleted, 1);
371 assert_eq!(
372 receipt.semantic_summary(),
373 "2 files · 1 updated · 1 renamed · +1 -1"
374 );
375 }
376
377 #[test]
378 fn failed_results_never_become_success_receipts() {
379 let failed = ToolResult::error("cancelled").with_metadata(json!({
380 "mutation": {
381 "diff": "--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n",
382 "files": [{ "path": "a", "outcome": "updated" }],
383 "renames": []
384 }
385 }));
386 assert!(FileMutationReceipt::from_success(Path::new("/workspace"), &failed).is_none());
387 }
388
389 #[test]
390 fn inline_diff_modes_are_bounded_and_keep_the_exact_detail_route() {
391 let additions = (0..30)
392 .map(|index| format!("+line {index}"))
393 .collect::<Vec<_>>()
394 .join("\n");
395 let result = result(json!({
396 "diff": format!("--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -0,0 +1,30 @@\n{additions}\n"),
397 "files": [{ "path": "src/lib.rs", "outcome": "created" }],
398 "renames": []
399 }));
400 let receipt =
401 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
402
403 let full = receipt.render_inline(80, InlineDiffMode::Full);
404 let full_text = plain(&full);
405 assert!(full.len() <= MAX_INLINE_DIFF_LINES + 2, "{full_text}");
406 assert!(full_text.contains("line 0"), "{full_text}");
407 assert!(full_text.contains("diff lines"), "{full_text}");
408 assert!(full_text.contains(":change"), "{full_text}");
409 assert!(!full_text.contains("summary:"), "{full_text}");
410
411 let summary = plain(&receipt.render_inline(80, InlineDiffMode::Summary));
412 assert!(summary.contains("Created src/lib.rs"), "{summary}");
413 assert!(summary.contains("+30 -0"), "{summary}");
414 assert!(!summary.contains("line 0"), "{summary}");
415 assert!(summary.contains(":change"), "{summary}");
416
417 let off = plain(&receipt.render_inline(80, InlineDiffMode::Off));
418 assert!(!off.contains("line 0"), "{off}");
419 assert!(!off.contains("+30 -0"), "{off}");
420 assert!(off.contains(":change"), "{off}");
421 }
422
423 #[test]
424 fn full_mode_spends_its_bound_on_red_green_evidence() {
425 let result = result(json!({
426 "diff": "diff --git a/old.rs b/new.rs\nsimilarity index 100%\nrename from old.rs\nrename to new.rs\ndiff --git a/lib.rs b/lib.rs\n--- a/lib.rs\n+++ b/lib.rs\n@@ -1 +1 @@\n-old\n+new\n",
427 "files": [{ "path": "lib.rs", "outcome": "updated" }],
428 "renames": [{ "from": "old.rs", "to": "new.rs" }]
429 }));
430 let receipt =
431 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
432 let full_text = plain(&receipt.render_inline(80, InlineDiffMode::Full));
433
434 assert!(full_text.contains("- old"), "{full_text}");
435 assert!(full_text.contains("+ new"), "{full_text}");
436 assert!(!full_text.contains("summary:"), "{full_text}");
437 }
438
439 #[test]
440 fn bounded_summary_never_truncates_semantic_stats() {
441 let long_path = format!("src/{}.rs", "whale".repeat(80));
442 let result = result(json!({
443 "diff": format!("--- a/{long_path}\n+++ b/{long_path}\n@@ -1 +1 @@\n-old\n+new\n"),
444 "files": [{ "path": long_path, "outcome": "updated" }],
445 "renames": []
446 }));
447 let receipt =
448 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
449 let summary = receipt.semantic_summary();
450 assert!(summary.ends_with(" · +1 -1"), "{summary}");
451 assert!(summary.chars().count() <= MAX_SUMMARY_CHARS);
452 }
453
454 #[test]
455 fn failed_file_cell_never_paints_a_forged_success_receipt() {
456 let receipt = FileMutationReceipt::from_success(
457 Path::new("/workspace"),
458 &result(json!({
459 "diff": "--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old\n+FORGED-SUCCESS\n",
460 "files": [{ "path": "a.rs", "outcome": "updated" }],
461 "renames": []
462 })),
463 )
464 .expect("receipt");
465 let cell = super::super::PatchSummaryCell {
466 path: "a.rs".to_string(),
467 summary: "editing".to_string(),
468 status: super::super::ToolStatus::Failed,
469 error: Some("cancelled".to_string()),
470 receipt: Some(receipt),
471 };
472
473 let text = plain(&cell.render(
474 80,
475 true,
476 super::super::RenderMode::Live,
477 InlineDiffMode::Full,
478 ));
479 assert!(text.contains("cancelled"), "{text}");
480 assert!(!text.contains("FORGED-SUCCESS"), "{text}");
481 }
482 }
483
483 lines RUST