返回 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::settings::InlineDiffMode;
14 use crate::tools::spec::ToolResult;
15 use crate::tui::diff_render;
16 use codewhale_palette as palette;
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_bounded(
188 &self.display_diff,
189 width,
190 MAX_INLINE_DIFF_LINES,
191 );
192 lines.extend(rendered.lines);
193 if rendered.omitted_rows > 0 {
194 let detail_hint =
195 crate::tui::key_shortcuts::tool_details_shortcut_action_hint("change");
196 lines.push(details_affordance_line(
197 &format!("+{} diff lines · {detail_hint}", rendered.omitted_rows),
198 Style::default().fg(palette::TEXT_MUTED).italic(),
199 ));
200 } else {
201 lines.push(exact_evidence_hint());
202 }
203 } else {
204 lines.push(exact_evidence_hint());
205 }
206 lines
207 }
208 }
209 }
210
211 fn count(&self, outcome: FileMutationOutcome) -> usize {
212 self.files
213 .iter()
214 .filter(|file| file.outcome == outcome)
215 .count()
216 }
217 }
218
219 fn exact_evidence_hint() -> Line<'static> {
220 details_affordance_line(
221 &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("change"),
222 Style::default().fg(palette::TEXT_MUTED).italic(),
223 )
224 }
225
226 fn push_count(parts: &mut Vec<String>, count: usize, label: &str) {
227 if count > 0 {
228 parts.push(format!("{count} {label}"));
229 }
230 }
231
232 fn bounded_text(value: &str, max_chars: usize) -> String {
233 if value.chars().count() <= max_chars {
234 return value.to_string();
235 }
236 if max_chars == 0 {
237 return String::new();
238 }
239 let mut bounded = value
240 .chars()
241 .take(max_chars.saturating_sub(1))
242 .collect::<String>();
243 bounded.push('…');
244 bounded
245 }
246
247 fn privacy_safe_path(workspace: &Path, raw: &str) -> String {
248 let normalized = raw.replace('\\', "/");
249 let workspace = workspace.to_string_lossy().replace('\\', "/");
250 let relative = if Path::new(raw).is_absolute() || normalized.starts_with('/') {
251 let prefix = workspace.trim_end_matches('/');
252 if normalized == prefix {
253 ""
254 } else if let Some(relative) = normalized.strip_prefix(&format!("{prefix}/")) {
255 relative
256 } else {
257 return "<external file>".to_string();
258 }
259 } else {
260 normalized.as_str()
261 };
262 let path = Path::new(relative);
263 if path.components().any(|component| {
264 matches!(
265 component,
266 Component::ParentDir | Component::RootDir | Component::Prefix(_)
267 )
268 }) {
269 return "<external file>".to_string();
270 }
271 let display = path.to_string_lossy().replace('\\', "/");
272 if display.is_empty() {
273 "<workspace>".to_string()
274 } else {
275 display
276 }
277 }
278
279 fn redact_diff_headers(workspace: &Path, diff: &str) -> String {
280 let mut redacted = diff
281 .lines()
282 .map(|line| {
283 for prefix in ["--- ", "+++ ", "rename from ", "rename to "] {
284 if let Some(raw) = line.strip_prefix(prefix) {
285 if raw == "/dev/null" {
286 return line.to_string();
287 }
288 let side = if raw.starts_with("a/") {
289 "a/"
290 } else if raw.starts_with("b/") {
291 "b/"
292 } else {
293 ""
294 };
295 let path = raw.strip_prefix(side).unwrap_or(raw);
296 return format!("{prefix}{side}{}", privacy_safe_path(workspace, path));
297 }
298 }
299 if let Some(rest) = line.strip_prefix("diff --git ") {
300 let mut paths = rest.split_whitespace();
301 if let (Some(old), Some(new)) = (paths.next(), paths.next()) {
302 let old = old.strip_prefix("a/").unwrap_or(old);
303 let new = new.strip_prefix("b/").unwrap_or(new);
304 return format!(
305 "diff --git a/{} b/{}",
306 privacy_safe_path(workspace, old),
307 privacy_safe_path(workspace, new)
308 );
309 }
310 }
311 line.to_string()
312 })
313 .collect::<Vec<_>>()
314 .join("\n");
315 if diff.ends_with('\n') {
316 redacted.push('\n');
317 }
318 redacted
319 }
320
321 #[cfg(test)]
322 mod tests {
323 use super::*;
324 use serde_json::json;
325
326 fn plain(lines: &[Line<'_>]) -> String {
327 lines
328 .iter()
329 .map(|line| {
330 line.spans
331 .iter()
332 .map(|span| span.content.as_ref())
333 .collect::<String>()
334 })
335 .collect::<Vec<_>>()
336 .join("\n")
337 }
338
339 fn result(mutation: Value) -> ToolResult {
340 ToolResult::success("ok").with_metadata(json!({ "mutation": mutation }))
341 }
342
343 #[test]
344 fn creates_bounded_semantic_summary_and_redacts_external_headers() {
345 let result = result(json!({
346 "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -0,0 +1 @@\n+secret\n",
347 "files": [{ "path": "/Users/alice/private.rs", "outcome": "created" }],
348 "renames": []
349 }));
350 let receipt =
351 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
352 assert_eq!(receipt.files[0].path, "<external file>");
353 assert!(receipt.exact_diff.contains("alice"));
354 assert!(!receipt.display_diff.contains("alice"));
355 assert!(
356 receipt
357 .semantic_summary()
358 .contains("Created <external file>")
359 );
360 }
361
362 #[test]
363 fn rename_and_multifile_outcomes_stay_semantic() {
364 let result = result(json!({
365 "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",
366 "files": [{ "path": "lib.rs", "outcome": "updated" }],
367 "renames": [{ "from": "old.rs", "to": "new.rs" }]
368 }));
369 let receipt =
370 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
371 assert_eq!(receipt.files.len(), 2);
372 assert_eq!(receipt.added, 1);
373 assert_eq!(receipt.deleted, 1);
374 assert_eq!(
375 receipt.semantic_summary(),
376 "2 files · 1 updated · 1 renamed · +1 -1"
377 );
378 }
379
380 #[test]
381 fn failed_results_never_become_success_receipts() {
382 let failed = ToolResult::error("cancelled").with_metadata(json!({
383 "mutation": {
384 "diff": "--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n",
385 "files": [{ "path": "a", "outcome": "updated" }],
386 "renames": []
387 }
388 }));
389 assert!(FileMutationReceipt::from_success(Path::new("/workspace"), &failed).is_none());
390 }
391
392 #[test]
393 fn inline_diff_modes_are_bounded_and_keep_the_exact_detail_route() {
394 let additions = (0..30)
395 .map(|index| format!("+line {index}"))
396 .collect::<Vec<_>>()
397 .join("\n");
398 let result = result(json!({
399 "diff": format!("--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -0,0 +1,30 @@\n{additions}\n"),
400 "files": [{ "path": "src/lib.rs", "outcome": "created" }],
401 "renames": []
402 }));
403 let receipt =
404 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
405
406 let full = receipt.render_inline(80, InlineDiffMode::Full);
407 let full_text = plain(&full);
408 assert!(full.len() <= MAX_INLINE_DIFF_LINES + 2, "{full_text}");
409 assert!(full_text.contains("line 0"), "{full_text}");
410 assert!(full_text.contains("diff lines"), "{full_text}");
411 assert!(full_text.contains(":change"), "{full_text}");
412 assert!(!full_text.contains("summary:"), "{full_text}");
413
414 let summary = plain(&receipt.render_inline(80, InlineDiffMode::Summary));
415 assert!(summary.contains("Created src/lib.rs"), "{summary}");
416 assert!(summary.contains("+30 -0"), "{summary}");
417 assert!(!summary.contains("line 0"), "{summary}");
418 assert!(summary.contains(":change"), "{summary}");
419
420 let off = plain(&receipt.render_inline(80, InlineDiffMode::Off));
421 assert!(!off.contains("line 0"), "{off}");
422 assert!(!off.contains("+30 -0"), "{off}");
423 assert!(off.contains(":change"), "{off}");
424 }
425
426 #[test]
427 fn full_mode_spends_its_bound_on_red_green_evidence() {
428 let result = result(json!({
429 "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",
430 "files": [{ "path": "lib.rs", "outcome": "updated" }],
431 "renames": [{ "from": "old.rs", "to": "new.rs" }]
432 }));
433 let receipt =
434 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
435 let full_text = plain(&receipt.render_inline(80, InlineDiffMode::Full));
436
437 assert!(full_text.contains("- old"), "{full_text}");
438 assert!(full_text.contains("+ new"), "{full_text}");
439 assert!(!full_text.contains("summary:"), "{full_text}");
440 }
441
442 #[test]
443 fn bounded_summary_never_truncates_semantic_stats() {
444 let long_path = format!("src/{}.rs", "whale".repeat(80));
445 let result = result(json!({
446 "diff": format!("--- a/{long_path}\n+++ b/{long_path}\n@@ -1 +1 @@\n-old\n+new\n"),
447 "files": [{ "path": long_path, "outcome": "updated" }],
448 "renames": []
449 }));
450 let receipt =
451 FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt");
452 let summary = receipt.semantic_summary();
453 assert!(summary.ends_with(" · +1 -1"), "{summary}");
454 assert!(summary.chars().count() <= MAX_SUMMARY_CHARS);
455 }
456
457 #[test]
458 fn failed_file_cell_never_paints_a_forged_success_receipt() {
459 let receipt = FileMutationReceipt::from_success(
460 Path::new("/workspace"),
461 &result(json!({
462 "diff": "--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old\n+FORGED-SUCCESS\n",
463 "files": [{ "path": "a.rs", "outcome": "updated" }],
464 "renames": []
465 })),
466 )
467 .expect("receipt");
468 let cell = super::super::PatchSummaryCell {
469 path: "a.rs".to_string(),
470 summary: "editing".to_string(),
471 status: super::super::ToolStatus::Failed,
472 error: Some("cancelled".to_string()),
473 receipt: Some(receipt),
474 };
475
476 let text = plain(&cell.render(
477 80,
478 true,
479 super::super::RenderMode::Live,
480 InlineDiffMode::Full,
481 ));
482 assert!(text.contains("cancelled"), "{text}");
483 assert!(!text.contains("FORGED-SUCCESS"), "{text}");
484 }
485 }
486
486 lines RUST