返回 CodeWhale
receipts.rs
根目录 / crates / tui / src / diagnostics_reports / receipts.rs
1 //! Shared pure receipt rendering, moved from `crate::receipts`.
2 //! All data classification/redaction and I/O remain in the authoritative builder.
3
4 use codewhale_command_contract::facets::debug_receipts::*;
5
6 pub(crate) fn bounded(text: &str, max: usize) -> String {
7 if text.chars().count() <= max {
8 return text.to_string();
9 }
10 let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
11 out.push('…');
12 out
13 }
14
15 /// One line of totals, verbs first: `Changed 4 files · ran 7 commands · 2
16 /// approved by you · 9 ran without asking under Full Access · 1 denied by
17 /// you`.
18 #[must_use]
19 pub fn totals_line(receipt: &Receipt) -> String {
20 let totals = &receipt.totals;
21 let mut parts: Vec<String> = Vec::new();
22 if totals.files_changed > 0 {
23 let mut part = format!("changed {}", plural(totals.files_changed, "file", "files"));
24 if totals.line_counts_complete {
25 part.push_str(&format!(
26 " (+{} −{})",
27 totals.lines_added, totals.lines_removed
28 ));
29 }
30 if totals.files_changed_outside_file_tools > 0 {
31 part.push_str(&format!(
32 ", {} outside file tools",
33 totals.files_changed_outside_file_tools
34 ));
35 }
36 parts.push(part);
37 }
38 if totals.commands > 0 {
39 let mut part = format!("ran {}", plural(totals.commands, "command", "commands"));
40 if totals.commands_failed > 0 {
41 part.push_str(&format!(" ({} failed)", totals.commands_failed));
42 }
43 parts.push(part);
44 }
45 if totals.code_runs > 0 {
46 parts.push(format!(
47 "ran code {}",
48 plural(totals.code_runs, "time", "times")
49 ));
50 }
51 if totals.network > 0 {
52 parts.push(format!(
53 "made {}",
54 plural(totals.network, "web request", "web requests")
55 ));
56 }
57 if totals.mcp_calls > 0 {
58 parts.push(format!(
59 "made {}",
60 plural(totals.mcp_calls, "MCP call", "MCP calls")
61 ));
62 }
63 if totals.subagents > 0 {
64 parts.push(format!(
65 "started {}",
66 plural(totals.subagents, "agent", "agents")
67 ));
68 }
69 let approvals = &totals.approvals;
70 let by_decider = |verb: &str, by: &DeciderCounts| -> Vec<String> {
71 [
72 (by.you, "by you"),
73 (by.session_rule, "by session rule"),
74 (by.posture, "by posture"),
75 (by.not_recorded, "(decider not recorded)"),
76 ]
77 .into_iter()
78 .filter(|(count, _)| *count > 0)
79 .map(|(count, who)| format!("{count} {verb} {who}"))
80 .collect()
81 };
82 parts.extend(by_decider("approved", &approvals.approved_by));
83 if totals.ran_without_asking > 0 {
84 let mut part = format!("{} ran without asking", totals.ran_without_asking);
85 if !receipt.postures.is_empty() {
86 part.push_str(&format!(" under {}", receipt.postures.join(" and ")));
87 }
88 parts.push(part);
89 }
90 parts.extend(by_decider("denied", &approvals.denied_by));
91 if approvals.timed_out > 0 {
92 parts.push(format!("{} timed out", approvals.timed_out));
93 }
94 if approvals.not_answered > 0 {
95 parts.push(format!("{} not answered", approvals.not_answered));
96 }
97 if approvals.pending > 0 {
98 parts.push(format!("{} waiting", approvals.pending));
99 }
100 if totals.blocked > 0 {
101 parts.push(format!("{} blocked before running", totals.blocked));
102 }
103 let failures_beyond_commands = totals.failures.saturating_sub(totals.commands_failed);
104 if failures_beyond_commands > 0 {
105 parts.push(plural(
106 failures_beyond_commands,
107 "other failure",
108 "other failures",
109 ));
110 }
111 if parts.is_empty() {
112 return if totals.other_tool_calls > 0 {
113 format!(
114 "Only read or looked things up ({}).",
115 plural(totals.other_tool_calls, "call", "calls")
116 )
117 } else {
118 "No actions recorded.".to_string()
119 };
120 }
121 let mut line = parts.join(" · ");
122 if let Some(first) = line.get(0..1) {
123 line = first.to_uppercase() + &line[1..];
124 }
125 line
126 }
127
128 pub(crate) fn plural(count: usize, one: &str, many: &str) -> String {
129 if count == 1 {
130 format!("1 {one}")
131 } else {
132 format!("{count} {many}")
133 }
134 }
135
136 fn decider_label(decider: ApprovalDecider) -> &'static str {
137 match decider {
138 ApprovalDecider::User => "you",
139 ApprovalDecider::SessionRule => "session rule",
140 ApprovalDecider::Posture => "posture",
141 ApprovalDecider::Host => "Codewhale",
142 }
143 }
144
145 fn approval_phrase(fact: &ApprovalFact) -> String {
146 let by = fact
147 .decided_by
148 .map(|by| format!(" by {}", decider_label(by)))
149 .unwrap_or_default();
150 match fact.decision {
151 ApprovalDecisionLabel::Approved => format!("approved{by}"),
152 ApprovalDecisionLabel::ApprovedWithPolicy => format!("approved{by} with a wider sandbox"),
153 ApprovalDecisionLabel::Denied => format!("denied{by}"),
154 ApprovalDecisionLabel::TimedOut => "approval timed out".to_string(),
155 ApprovalDecisionLabel::Cancelled => "turn stopped while waiting".to_string(),
156 ApprovalDecisionLabel::Unavailable => "nobody could be asked".to_string(),
157 ApprovalDecisionLabel::Pending => "waiting for approval".to_string(),
158 }
159 }
160
161 fn counts(added: Option<u64>, removed: Option<u64>) -> String {
162 match (added, removed) {
163 (Some(added), Some(removed)) => format!(" (+{added} −{removed})"),
164 _ => String::new(),
165 }
166 }
167
168 fn file_phrase(file: &FileTouch) -> String {
169 let verb = match file.change {
170 FileChangeKind::Edited => "edited",
171 FileChangeKind::Created => "created",
172 FileChangeKind::Deleted => "deleted",
173 FileChangeKind::Written => "wrote",
174 };
175 let counts = if file.change == FileChangeKind::Deleted {
176 String::new()
177 } else {
178 counts(file.lines_added, file.lines_removed)
179 };
180 format!("{verb} {}{counts}", code_span(&file.path))
181 }
182
183 /// `text` on one line with nothing a terminal acts on: control characters
184 /// (newline, carriage return, escape), Unicode line separators, and bidi
185 /// overrides show as `\u{…}`-style escapes. Paths, commands, and error text
186 /// come from the workspace and from tools, so a file a command named
187 /// `x\n- Ran …` cannot forge a receipt line, and one holding `ESC ]` cannot
188 /// drive the terminal that prints the receipt.
189 fn one_line(text: &str) -> String {
190 let mut out = String::with_capacity(text.len());
191 for ch in text.chars() {
192 let hidden = ch.is_control()
193 || matches!(
194 ch,
195 '\u{2028}'
196 | '\u{2029}'
197 | '\u{200e}'
198 | '\u{200f}'
199 | '\u{202a}'..='\u{202e}'
200 | '\u{2066}'..='\u{2069}'
201 );
202 if hidden {
203 out.extend(ch.escape_default());
204 } else {
205 out.push(ch);
206 }
207 }
208 out
209 }
210
211 /// Inline code that survives backticks in the text: the fence is one
212 /// backtick longer than the longest run inside it.
213 pub(crate) fn code_span(text: &str) -> String {
214 let longest = text.split(|ch| ch != '`').map(str::len).max().unwrap_or(0);
215 let fence = "`".repeat(longest + 1);
216 let pad = if text.starts_with('`') || text.ends_with('`') {
217 " "
218 } else {
219 ""
220 };
221 format!("{fence}{pad}{text}{pad}{fence}")
222 }
223
224 /// What the action did (or would have done), past tense and verb first.
225 /// Every phrase starts with one of [`PHRASE_VERBS`], so a call that did not
226 /// run can say so in the same words.
227 fn action_phrase(action: &ReceiptAction) -> String {
228 match &action.what {
229 ActionKind::FileChange { files } => match files.as_slice() {
230 [file] => file_phrase(file),
231 files if action.status == ActionStatus::Ok => format!(
232 "changed {} files: {}",
233 files.len(),
234 files.iter().map(file_phrase).collect::<Vec<_>>().join(", ")
235 ),
236 files => format!(
237 "changed {} files: {}",
238 files.len(),
239 files
240 .iter()
241 .map(|file| code_span(&file.path))
242 .collect::<Vec<_>>()
243 .join(", ")
244 ),
245 },
246 ActionKind::WorkspaceChange { files, truncated } => {
247 let mut text = format!(
248 "changed outside file tools (a command or another process): {}",
249 files.iter().map(file_phrase).collect::<Vec<_>>().join(", ")
250 );
251 if *truncated {
252 text.push_str(", and more");
253 }
254 text
255 }
256 ActionKind::Command {
257 command,
258 cwd,
259 exit_code,
260 } => {
261 let mut text = format!("ran {}", code_span(command));
262 if let Some(cwd) = cwd {
263 text.push_str(&format!(" in {cwd}"));
264 }
265 if let Some(code) = exit_code {
266 text.push_str(&format!(" — exit {code}"));
267 }
268 text
269 }
270 ActionKind::Code { exit_code, nested } => {
271 let mut text = format!("ran code ({})", action.tool);
272 if let Some(code) = exit_code {
273 text.push_str(&format!(" — exit {code}"));
274 }
275 if !nested.is_empty() {
276 let calls = nested
277 .iter()
278 .map(|call| format!("{}{}", call.tool, if call.ok { "" } else { " ✗" }))
279 .collect::<Vec<_>>()
280 .join(", ");
281 text.push_str(&format!(" — called {calls}"));
282 }
283 text
284 }
285 ActionKind::Network {
286 action: kind,
287 host,
288 query,
289 } => match (kind.as_str(), host, query) {
290 ("search", _, Some(query)) => format!("searched the web for “{query}”"),
291 ("search", _, None) => "searched the web".to_string(),
292 ("fetch", Some(host), _) => format!("fetched {host}"),
293 ("git_fetch", Some(remote), _) => format!("fetched git remote {remote}"),
294 (other, Some(host), _) => format!("called {host}: {}", other.replace('_', " ")),
295 (other, None, _) => format!("made a web request ({other})"),
296 },
297 ActionKind::Mcp { server, .. } => {
298 let tool = server
299 .as_deref()
300 .and_then(|server| {
301 action
302 .tool
303 .strip_prefix("mcp_")
304 .and_then(|rest| rest.strip_prefix(server))
305 .map(|rest| rest.trim_start_matches('_'))
306 })
307 .filter(|tool| !tool.is_empty());
308 match (server, tool) {
309 (Some(server), Some(tool)) => format!("called {server} · {tool}"),
310 _ => format!("called {}", action.tool),
311 }
312 }
313 ActionKind::Subagent {
314 name,
315 agent_id,
316 outcome,
317 } => {
318 let who = name
319 .as_deref()
320 .or(agent_id.as_deref())
321 .unwrap_or("an agent");
322 match outcome {
323 Some(outcome) => format!("started agent {who} — {outcome}"),
324 None => format!("started agent {who}"),
325 }
326 }
327 ActionKind::Approval => format!("asked to use {}", action.tool),
328 ActionKind::Tool => format!("called {}", action.tool),
329 ActionKind::TurnFailed => "turn failed".to_string(),
330 }
331 }
332
333 /// The past-tense verbs [`action_phrase`] starts with, and their base form.
334 const PHRASE_VERBS: [(&str, &str); 11] = [
335 ("ran ", "run "),
336 ("edited ", "edit "),
337 ("created ", "create "),
338 ("deleted ", "delete "),
339 ("wrote ", "write "),
340 ("changed ", "change "),
341 ("searched ", "search "),
342 ("fetched ", "fetch "),
343 ("called ", "call "),
344 ("started ", "start "),
345 ("made ", "make "),
346 ];
347
348 /// `ran `x`` becomes `did not run `x`` (lead `did not`) or `tried to run
349 /// `x`` (lead `tried to`).
350 fn with_base_verb(lead: &str, phrase: &str) -> String {
351 PHRASE_VERBS
352 .iter()
353 .find_map(|(past, base)| {
354 phrase
355 .strip_prefix(past)
356 .map(|rest| format!("{lead} {base}{rest}"))
357 })
358 .unwrap_or_else(|| format!("{lead} run: {phrase}"))
359 }
360
361 fn duration_label(ms: u64) -> String {
362 if ms < 1_000 {
363 format!("{ms}ms")
364 } else if ms < 60_000 {
365 format!("{:.1}s", ms as f64 / 1_000.0)
366 } else {
367 format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1_000)
368 }
369 }
370
371 /// One line per action. Plain text that also reads as Markdown; whatever
372 /// the record holds cannot break it onto a second line ([`one_line`]).
373 #[must_use]
374 pub fn action_line(action: &ReceiptAction) -> String {
375 let mut line = action_phrase(action);
376 match action.status {
377 // An approval line already reads as a request, not a run.
378 ActionStatus::NotRun if action.what == ActionKind::Approval => {}
379 ActionStatus::NotRun => line = with_base_verb("did not", &line),
380 ActionStatus::Blocked => {
381 line = with_base_verb("did not", &line);
382 line.push_str(" — blocked");
383 if let Some(error) = &action.error {
384 line.push_str(&format!(": {error}"));
385 }
386 }
387 ActionStatus::Failed => {
388 line.push_str(" — failed");
389 if let Some(error) = &action.error {
390 line.push_str(&format!(": {error}"));
391 }
392 }
393 ActionStatus::Interrupted => line.push_str(" — interrupted"),
394 ActionStatus::Running if action.what != ActionKind::Approval => {
395 line.push_str(" — still running")
396 }
397 ActionStatus::Unknown => {
398 line = with_base_verb("tried to", &line);
399 match &action.error {
400 Some(error) => line.push_str(&format!(" — error, no exit code: {error}")),
401 None => line.push_str(" — no result recorded"),
402 }
403 }
404 _ => {}
405 }
406 if let Some(ms) = action.duration_ms
407 && !matches!(action.status, ActionStatus::NotRun | ActionStatus::Blocked)
408 {
409 line.push_str(&format!(" · {}", duration_label(ms)));
410 }
411 if let Some(fact) = &action.approval {
412 line.push_str(&format!(" · {}", approval_phrase(fact)));
413 }
414 one_line(&line)
415 }
416
417 /// The readable receipt: header, totals, one line per action, then what the
418 /// record does not hold. Valid Markdown and plain enough for a terminal.
419 #[must_use]
420 pub fn render_markdown(receipt: &Receipt) -> String {
421 let source = &receipt.source;
422 let noun = match source.kind {
423 SourceKind::Session => "session",
424 SourceKind::Thread => "thread",
425 };
426 let mut out = String::new();
427 let title = source
428 .title
429 .as_deref()
430 .map(|title| bounded(title.trim(), 80))
431 .filter(|title| !title.is_empty());
432 match title {
433 Some(title) => out.push_str(&format!("# Receipt: {}\n\n", one_line(&title))),
434 None => out.push_str(&format!("# Receipt: {noun} {}\n\n", one_line(&source.id))),
435 }
436 let mut facts = vec![format!("{noun} {}", source.id)];
437 if let Some(turn) = &receipt.turn {
438 facts.push(format!("turn {turn} only"));
439 }
440 if let Some(workspace) = &source.workspace {
441 facts.push(workspace.clone());
442 }
443 if let Some(model) = &source.model {
444 facts.push(model.clone());
445 }
446 if !receipt.postures.is_empty() {
447 facts.push(receipt.postures.join(", "));
448 }
449 if let (Some(start), Some(end)) = (source.started_at, source.updated_at) {
450 facts.push(format!(
451 "{} → {}",
452 start.format("%Y-%m-%d %H:%M UTC"),
453 end.format("%Y-%m-%d %H:%M UTC")
454 ));
455 }
456 out.push_str(&one_line(&facts.join(" · ")));
457 out.push_str("\n\n");
458 out.push_str(&one_line(&totals_line(receipt)));
459 out.push_str("\n\n");
460 let width = receipt.actions.len().to_string().len();
461 for action in &receipt.actions {
462 out.push_str(&format!(
463 "{:>width$}. {}\n",
464 action.seq,
465 action_line(action),
466 width = width
467 ));
468 }
469 if receipt.omitted_actions > 0 {
470 out.push_str(&format!(
471 "\n{} more actions are counted above but not listed.\n",
472 receipt.omitted_actions
473 ));
474 }
475 if !receipt.not_recorded.is_empty() {
476 out.push_str("\nNot recorded:\n");
477 for note in &receipt.not_recorded {
478 out.push_str(&format!("- {}\n", one_line(note)));
479 }
480 }
481 out
482 }
483
484 #[must_use]
485 pub fn render_json(receipt: &Receipt) -> String {
486 serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string())
487 }
488
489 /// [`render_json`] inside a fenced code block, for a surface that renders
490 /// Markdown (the terminal's note cell): the fence keeps `$`, `*`, and `_` in
491 /// commands from being read as math or emphasis, and it is longer than any
492 /// backtick run a command holds, so the JSON copies out whole.
493 #[must_use]
494 pub fn render_json_block(receipt: &Receipt) -> String {
495 let json = render_json(receipt);
496 let longest = json.split(|ch| ch != '`').map(str::len).max().unwrap_or(0);
497 let fence = "`".repeat(longest.max(2) + 1);
498 format!("{fence}json\n{json}\n{fence}")
499 }
500
500 lines RUST