返回 CodeWhale
export.rs
根目录 / crates / tui / src / commands / groups / session / export.rs
1 //! `/export` command — portable handler over the session-export facet
2 //! (FEAT-025 D1-D9).
3 //!
4 //! Parsing, document rendering, redaction, operation sequencing, and result
5 //! composition are portable: this module depends only on the external command
6 //! contract, `chrono`, the shared pure sanitizer in
7 //! [`codewhale_secrets::sanitize`], and the temporary FEAT-037 `CommandResult`.
8 //! Concrete `App`, clipboard, filesystem, snapshot, history, and turn-handoff
9 //! access stays behind `CommandSessionExportContext` (D1). Helpers, tests, and
10 //! this handler therefore carry no TUI, client, configuration, or filesystem
11 //! dependency, so the slice can move to `codewhale-commands` unchanged (D8/D10).
12
13 use std::fmt::Write as FmtWrite;
14 use std::path::PathBuf;
15
16 use codewhale_command_contract::facets::{
17 CommandSessionExportContext, ConversationExportProjection, ExportBlock, ExportMessage,
18 HistoryEntry, RestorePointProjection, RestoreSnapshot, TranscriptProjection,
19 TurnHandoffProjection,
20 };
21 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
22 use codewhale_command_contract::metadata::{
23 CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand,
24 };
25 use codewhale_secrets::sanitize::{
26 inline_text, is_internal_role, redact_json, redact_url_for_display, sanitize_text,
27 };
28 use serde_json::Value;
29
30 use super::CommandResult;
31
32 pub(in crate::commands) struct ExportCmd;
33
34 pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
35 name: "export",
36 aliases: &["daochu"],
37 usage: "/export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]",
38 description_key: "cmd_export_description",
39 };
40
41 impl ContractRegisterCommand<CommandResult> for ExportCmd {
42 fn info() -> &'static ContractInfo {
43 &CONTRACT_INFO
44 }
45
46 fn handler() -> CommandHandler<CommandResult> {
47 CommandHandler::Contextual {
48 capabilities: CommandCapabilities::SESSION_EXPORT,
49 handler: export_contextual,
50 }
51 }
52 }
53
54 pub(in crate::commands) fn export_contextual(
55 contexts: CommandContexts<'_>,
56 arg: Option<&str>,
57 ) -> CommandResult {
58 let parts = contexts.into_parts();
59 let Some(export) = parts.export.as_deref() else {
60 return CommandResult::error("Command capability unavailable: session_export".to_string());
61 };
62 export_portable(export, arg)
63 }
64
65 /// Portable `/export` composed entirely from contract-owned data and facet
66 /// operations. Parse first, render the selected scope second, then run the
67 /// destination-specific sequence (D6/D7).
68 pub(in crate::commands) fn export_portable(
69 export: &dyn CommandSessionExportContext,
70 arg: Option<&str>,
71 ) -> CommandResult {
72 let request = match parse_request(arg) {
73 Ok(request) => request,
74 Err(err) => return CommandResult::error(err),
75 };
76 let label = match request.scope {
77 ExportScope::Conversation => "Conversation",
78 ExportScope::Turn => "Turn handoff",
79 };
80 let markdown = match request.scope {
81 ExportScope::Conversation => render_conversation(export.conversation_projection()),
82 ExportScope::Turn => sanitize_turn_handoff(&export.turn_handoff_projection()),
83 };
84
85 match request.destination {
86 ExportDestination::Clipboard => copy_to_clipboard(export, label, &markdown),
87 ExportDestination::File { path, force } => {
88 let path = match export.resolve_export_path(&path) {
89 Ok(path) => path,
90 Err(err) => return CommandResult::error(err),
91 };
92 match export.write_export_file(&path, markdown.as_bytes(), force) {
93 Ok(()) => CommandResult::message(format!(
94 "{label} exported to {}{}",
95 path.display(),
96 if force {
97 " (overwrite explicitly allowed)"
98 } else {
99 ""
100 }
101 )),
102 Err(err) => CommandResult::error(format!(
103 "Failed to export {label} to {}: {err}",
104 path.display()
105 )),
106 }
107 }
108 }
109 }
110
111 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112 enum ExportScope {
113 Conversation,
114 Turn,
115 }
116
117 #[derive(Debug, Clone, PartialEq, Eq)]
118 enum ExportDestination {
119 Clipboard,
120 File { path: String, force: bool },
121 }
122
123 #[derive(Debug, Clone, PartialEq, Eq)]
124 struct ExportRequest {
125 scope: ExportScope,
126 destination: ExportDestination,
127 }
128
129 fn parse_request(arg: Option<&str>) -> Result<ExportRequest, String> {
130 let raw = arg.unwrap_or("").trim();
131 if raw.is_empty() || raw.eq_ignore_ascii_case("clipboard") {
132 return Ok(ExportRequest {
133 scope: ExportScope::Conversation,
134 destination: ExportDestination::Clipboard,
135 });
136 }
137
138 if raw.eq_ignore_ascii_case("turn") {
139 return Ok(ExportRequest {
140 scope: ExportScope::Turn,
141 destination: ExportDestination::Clipboard,
142 });
143 }
144
145 if let Some(rest) = strip_word(raw, "turn") {
146 let rest = rest.trim();
147 if rest.is_empty() || rest.eq_ignore_ascii_case("clipboard") {
148 return Ok(ExportRequest {
149 scope: ExportScope::Turn,
150 destination: ExportDestination::Clipboard,
151 });
152 }
153 let destination = if let Some(file_args) = strip_word(rest, "file") {
154 parse_file_destination(file_args)?
155 } else if rest.eq_ignore_ascii_case("file") {
156 return Err(export_usage("missing file path"));
157 } else if strip_word(rest, "clipboard").is_some() {
158 return Err(export_usage("clipboard does not accept a path"));
159 } else {
160 // Backward compatibility: `/export turn <path>`.
161 ExportDestination::File {
162 path: rest.to_string(),
163 force: false,
164 }
165 };
166 return Ok(ExportRequest {
167 scope: ExportScope::Turn,
168 destination,
169 });
170 }
171
172 if let Some(file_args) = strip_word(raw, "file") {
173 return Ok(ExportRequest {
174 scope: ExportScope::Conversation,
175 destination: parse_file_destination(file_args)?,
176 });
177 }
178 if raw.eq_ignore_ascii_case("file") {
179 return Err(export_usage("missing file path"));
180 }
181 if strip_word(raw, "clipboard").is_some() {
182 return Err(export_usage("clipboard does not accept a path"));
183 }
184
185 // Backward compatibility: `/export <path>`.
186 Ok(ExportRequest {
187 scope: ExportScope::Conversation,
188 destination: ExportDestination::File {
189 path: raw.to_string(),
190 force: false,
191 },
192 })
193 }
194
195 fn parse_file_destination(raw: &str) -> Result<ExportDestination, String> {
196 let trimmed = raw.trim();
197 let (force, path) = if let Some(path) = strip_word(trimmed, "--force") {
198 (true, path.trim())
199 } else if trimmed.eq_ignore_ascii_case("--force") {
200 (true, "")
201 } else {
202 (false, trimmed)
203 };
204 if path.is_empty() {
205 return Err(export_usage("missing file path"));
206 }
207 Ok(ExportDestination::File {
208 path: path.to_string(),
209 force,
210 })
211 }
212
213 fn strip_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
214 let prefix = value.get(..word.len())?;
215 if !prefix.eq_ignore_ascii_case(word) {
216 return None;
217 }
218 let rest = value.get(word.len()..)?;
219 rest.chars()
220 .next()
221 .is_some_and(char::is_whitespace)
222 .then_some(rest)
223 }
224
225 fn export_usage(reason: &str) -> String {
226 format!(
227 "{reason}. Usage: /export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]"
228 )
229 }
230
231 fn copy_to_clipboard(
232 export: &dyn CommandSessionExportContext,
233 label: &str,
234 markdown: &str,
235 ) -> CommandResult {
236 let terminal_client = export.clipboard_requires_terminal_paste();
237 let last_copy = export.write_recovery_copy(markdown);
238 let copy_hint = |path: Option<PathBuf>| match path {
239 Some(path) => {
240 format!("; a copy is at {}", path.display())
241 }
242 None => String::new(),
243 };
244 match export.write_clipboard(markdown) {
245 Ok(()) if terminal_client => CommandResult::message(format!(
246 "{label} sent to the terminal-client clipboard over SSH via tmux/OSC 52 ({} lines){}; terminal support and settings determine whether the client accepts it",
247 markdown.lines().count(),
248 copy_hint(last_copy)
249 )),
250 Ok(()) => CommandResult::message(format!(
251 "{label} copied to the local clipboard ({} lines; a terminal clipboard fallback may have been used){}",
252 markdown.lines().count(),
253 copy_hint(last_copy)
254 )),
255 Err(err) => match last_copy {
256 Some(path) => CommandResult::error(format!(
257 "Clipboard export failed: {err}. The full export was written to {}; /export file <path> writes it where you choose",
258 path.display()
259 )),
260 None => CommandResult::error(format!(
261 "Clipboard export failed: {err}. No file was written; use `/export file <path>` to choose an explicit destination"
262 )),
263 },
264 }
265 }
266
267 /// Render the full-conversation export document from portable projection data.
268 ///
269 /// Takes the projection by value so the render path can move each block's JSON
270 /// payload into `push_json` instead of cloning it a second time. The projection
271 /// itself was already copied once at the facet boundary (F3); cloning the
272 /// payloads again here would be an avoidable extra copy of every tool input and
273 /// structured result.
274 fn render_conversation(projection: ConversationExportProjection) -> String {
275 let ConversationExportProjection {
276 metadata,
277 transcript,
278 restore_points,
279 } = projection;
280 let mut out = String::new();
281 out.push_str("# Codewhale conversation export\n\n");
282 let _ = writeln!(
283 out,
284 "- Exported: {}",
285 format_export_time(metadata.exported_at_unix)
286 );
287 let _ = writeln!(out, "- Session: {}", inline_text(&metadata.session_label));
288 let _ = writeln!(out, "- Provider: {}", inline_text(&metadata.provider));
289 let _ = writeln!(out, "- Model: {}", inline_text(&metadata.model));
290 let _ = writeln!(out, "- Mode: {}", metadata.mode);
291 let _ = writeln!(
292 out,
293 "- Workspace: {}",
294 inline_text(&metadata.workspace_name)
295 );
296 let _ = writeln!(out, "- Messages: {}", metadata.message_count);
297 out.push_str(
298 "\n> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.\n\n",
299 );
300
301 render_restore_summary(&mut out, &restore_points);
302
303 match transcript {
304 TranscriptProjection::HistoryFallback(entries) => {
305 render_history_fallback(&mut out, &entries)
306 }
307 TranscriptProjection::Authoritative(messages) => {
308 for (index, message) in messages.into_iter().enumerate() {
309 // Correlation is derived before `message` is consumed by the
310 // renderer so the render path can take ownership of its payloads.
311 let correlation = correlation_markdown(&restore_points, &message);
312 render_message(&mut out, index + 1, message);
313 out.push_str(&correlation);
314 }
315 }
316 }
317 out
318 }
319
320 fn format_export_time(timestamp: i64) -> String {
321 chrono::DateTime::from_timestamp(timestamp, 0)
322 .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
323 .unwrap_or_else(|| "unknown".to_string())
324 }
325
326 /// Characters of the snapshot SHA shown as a restore-point id.
327 const RESTORE_POINT_ID_LEN: usize = 12;
328
329 fn render_restore_summary(out: &mut String, projection: &RestorePointProjection) {
330 out.push_str("## Restore points\n\n");
331 match projection {
332 RestorePointProjection::None => {
333 out.push_str(
334 "No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.\n\n",
335 );
336 }
337 RestorePointProjection::Unreadable { reason } => {
338 let _ = writeln!(
339 out,
340 "Workspace restore points could not be read ({}). Treat the correlation below as unavailable rather than empty.\n",
341 inline_text(reason)
342 );
343 }
344 RestorePointProjection::Recorded { snapshots } if snapshots.is_empty() => {
345 out.push_str(
346 "A snapshot repository exists for this workspace but records no restore points yet.\n\n",
347 );
348 }
349 RestorePointProjection::Recorded { snapshots } => {
350 let _ = writeln!(
351 out,
352 "The {} most recent workspace restore points, newest first. `/restore <N>` restores by the index in this table and `/restore list` shows the live list.\n",
353 snapshots.len()
354 );
355 out.push_str(
356 "> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift.\n\n",
357 );
358 out.push_str("| N | Restore point | Recorded (UTC) | Label |\n");
359 out.push_str("| --- | --- | --- | --- |\n");
360 for (index, snapshot) in snapshots.iter().enumerate() {
361 let _ = writeln!(
362 out,
363 "| {} | `{}` | {} | {} |",
364 index + 1,
365 short_restore_id(&snapshot.id),
366 format_snapshot_time(snapshot.timestamp_unix),
367 inline_text(&snapshot.label)
368 );
369 }
370 out.push('\n');
371 }
372 }
373 }
374
375 /// Append the restore points correlated to a single user message.
376 ///
377 /// Correlation is by the prompt snippet the host embedded in the snapshot
378 /// label, produced by the same function the snapshot writer uses. No
379 /// message-index-to-turn-sequence mapping is invented: a turn sequence and an
380 /// export message index are different counters, and asserting they line up
381 /// would be a guess presented as provenance.
382 fn correlation_markdown(projection: &RestorePointProjection, message: &ExportMessage) -> String {
383 let mut out = String::new();
384 // F6: exact `Role::User` identity, not the rendered role string. Comparing
385 // textually against "user" would also match `Role::Unrecognized("user")`,
386 // which the baseline deliberately did not correlate.
387 if !message.is_user_role {
388 return out;
389 }
390 let RestorePointProjection::Recorded { snapshots } = projection else {
391 return out;
392 };
393 let Some(snippet) = message.prompt_snippet.as_deref() else {
394 return out;
395 };
396
397 let matches: Vec<(usize, &RestoreSnapshot)> = snapshots
398 .iter()
399 .enumerate()
400 .filter(|(_, snapshot)| {
401 matches!(snapshot.kind.as_str(), "pre-turn" | "post-turn")
402 && snapshot.prompt_snippet.as_deref() == Some(snippet)
403 })
404 .collect();
405
406 if matches.is_empty() {
407 out.push_str(
408 "- Restore points: none recorded for this message within the listed window.\n\n",
409 );
410 return out;
411 }
412
413 let ambiguous = matches.len() > 1;
414 let rendered: Vec<String> = matches
415 .iter()
416 .map(|(index, snapshot)| {
417 let seq = snapshot
418 .sequence
419 .map(|seq| format!(" turn {seq}"))
420 .unwrap_or_default();
421 format!(
422 "N{} `{}` ({}{})",
423 index + 1,
424 short_restore_id(&snapshot.id),
425 snapshot.kind,
426 seq
427 )
428 })
429 .collect();
430 let _ = writeln!(out, "- Restore points: {}", rendered.join(", "));
431 if ambiguous {
432 out.push_str(
433 " - More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring.\n",
434 );
435 }
436 out.push('\n');
437 out
438 }
439
440 fn short_restore_id(id: &str) -> String {
441 id.chars().take(RESTORE_POINT_ID_LEN).collect()
442 }
443
444 fn format_snapshot_time(timestamp: i64) -> String {
445 chrono::DateTime::from_timestamp(timestamp, 0)
446 .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
447 .unwrap_or_else(|| "unknown".to_string())
448 }
449
450 fn render_message(out: &mut String, index: usize, message: ExportMessage) {
451 let role = inline_text(&message.role);
452 let _ = writeln!(out, "## {index}. {role}\n");
453 if is_internal_role(&message.role) {
454 out.push_str("[internal context omitted]\n\n");
455 return;
456 }
457 if message.blocks.is_empty() {
458 out.push_str("[no content]\n\n");
459 return;
460 }
461 for (block_index, block) in message.blocks.into_iter().enumerate() {
462 render_content_block(out, block_index + 1, block);
463 }
464 }
465
466 fn render_content_block(out: &mut String, index: usize, block: ExportBlock) {
467 match block {
468 ExportBlock::Text { text } => {
469 let _ = writeln!(out, "### Content {index}: Text\n");
470 push_sanitized_text(out, &text);
471 }
472 ExportBlock::ImageReference { url } => {
473 let _ = writeln!(out, "### Content {index}: Image attachment\n");
474 let _ = writeln!(
475 out,
476 "- Reference: {}\n",
477 inline_text(&redact_url_for_display(&url))
478 );
479 }
480 ExportBlock::ImageOmitted => {
481 let _ = writeln!(out, "### Content {index}: Image attachment\n");
482 out.push_str("- Reference omitted (inline or local image payload)\n\n");
483 }
484 ExportBlock::InternalReasoning => {
485 let _ = writeln!(out, "### Content {index}: Internal reasoning\n");
486 out.push_str("[internal reasoning and signature omitted]\n\n");
487 }
488 ExportBlock::ToolCall {
489 id,
490 name,
491 caller,
492 input,
493 } => {
494 let _ = writeln!(out, "### Content {index}: Tool call\n");
495 let _ = writeln!(out, "- ID: {}", inline_text(&id));
496 let _ = writeln!(out, "- Name: {}", inline_text(&name));
497 if let Some(caller) = caller {
498 let _ = writeln!(out, "- Caller type: {}", inline_text(&caller.caller_type));
499 if let Some(tool_id) = caller.tool_id.as_deref() {
500 let _ = writeln!(out, "- Caller tool ID: {}", inline_text(tool_id));
501 }
502 }
503 out.push_str("\nInput:\n\n");
504 push_json(out, input);
505 }
506 ExportBlock::ToolResult {
507 tool_use_id,
508 content,
509 is_error,
510 structured,
511 } => {
512 let _ = writeln!(out, "### Content {index}: Tool result\n");
513 let _ = writeln!(out, "- Tool call ID: {}", inline_text(&tool_use_id));
514 let _ = writeln!(out, "- Error: {is_error}\n");
515 out.push_str("Result:\n\n");
516 push_sanitized_text(out, &content);
517 if let Some(blocks) = structured {
518 out.push_str("Structured result blocks:\n\n");
519 push_json(out, blocks);
520 }
521 }
522 ExportBlock::ServerToolCall { id, name, input } => {
523 let _ = writeln!(out, "### Content {index}: Server tool call\n");
524 let _ = writeln!(out, "- ID: {}", inline_text(&id));
525 let _ = writeln!(out, "- Name: {}\n", inline_text(&name));
526 out.push_str("Input:\n\n");
527 push_json(out, input);
528 }
529 ExportBlock::ToolSearchResult {
530 tool_use_id,
531 content,
532 } => {
533 let _ = writeln!(out, "### Content {index}: Tool-search result\n");
534 let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(&tool_use_id));
535 push_json(out, content);
536 }
537 ExportBlock::CodeExecutionResult {
538 tool_use_id,
539 content,
540 } => {
541 let _ = writeln!(out, "### Content {index}: Code-execution result\n");
542 let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(&tool_use_id));
543 push_json(out, content);
544 }
545 }
546 }
547
548 fn render_history_fallback(out: &mut String, entries: &[HistoryEntry]) {
549 if entries.is_empty() {
550 out.push_str("## Conversation\n\n[empty conversation]\n");
551 return;
552 }
553 out.push_str(
554 "> Structured API messages were unavailable; the entries below are a sanitized visible-history fallback.\n\n",
555 );
556 for (index, entry) in entries.iter().enumerate() {
557 let (role, body) = match entry {
558 HistoryEntry::Sanitized { role, body } => (role.as_str(), sanitize_text(body)),
559 HistoryEntry::Literal { role, body } => (role.as_str(), body.clone()),
560 };
561 let _ = writeln!(out, "## {}. {}\n", index + 1, inline_text(role));
562 push_pre_sanitized_text(out, &body);
563 }
564 }
565
566 fn push_sanitized_text(out: &mut String, text: &str) {
567 push_pre_sanitized_text(out, &sanitize_text(text));
568 }
569
570 fn push_pre_sanitized_text(out: &mut String, text: &str) {
571 if text.trim().is_empty() {
572 out.push_str("[empty text]\n\n");
573 } else {
574 out.push_str(text.trim_end());
575 out.push_str("\n\n");
576 }
577 }
578
579 fn push_json(out: &mut String, mut value: Value) {
580 // Redact in place: the caller hands over ownership, so there is no need to
581 // clone the whole payload just to redact it (F3 follow-up).
582 redact_json(&mut value, None);
583 let json = serde_json::to_string_pretty(&value)
584 .unwrap_or_else(|_| "\"[structured content unavailable]\"".to_string());
585 let fence = markdown_fence(&json);
586 let _ = writeln!(out, "{fence}json\n{json}\n{fence}\n");
587 }
588
589 fn markdown_fence(content: &str) -> String {
590 let longest = content
591 .split(|ch| ch != '`')
592 .map(str::len)
593 .max()
594 .unwrap_or(0);
595 "`".repeat(longest.saturating_add(1).max(3))
596 }
597
598 fn sanitize_turn_handoff(projection: &TurnHandoffProjection) -> String {
599 let sanitized = sanitize_text(&projection.markdown);
600 if projection.workspace_path.is_empty() {
601 sanitized
602 } else {
603 sanitized.replace(&projection.workspace_path, ".")
604 }
605 }
606
607 #[cfg(test)]
608 mod tests {
609 use super::*;
610 use std::cell::RefCell;
611 use std::path::Path;
612
613 /// Minimal fake facet: every delegate is deterministic and records calls.
614 struct FakeExport {
615 conversation: ConversationExportProjection,
616 turn: TurnHandoffProjection,
617 terminal_paste: bool,
618 recovery: Option<PathBuf>,
619 clipboard: Result<(), String>,
620 resolve: Result<PathBuf, String>,
621 write: Result<(), String>,
622 calls: RefCell<Vec<String>>,
623 }
624
625 impl Default for FakeExport {
626 fn default() -> Self {
627 Self {
628 conversation: conversation_projection(vec![]),
629 turn: TurnHandoffProjection {
630 markdown: String::new(),
631 workspace_path: String::new(),
632 },
633 terminal_paste: false,
634 recovery: None,
635 clipboard: Ok(()),
636 resolve: Ok(PathBuf::from("/resolved/out.md")),
637 write: Ok(()),
638 calls: RefCell::new(Vec::new()),
639 }
640 }
641 }
642
643 impl CommandSessionExportContext for FakeExport {
644 fn conversation_projection(&self) -> ConversationExportProjection {
645 self.calls
646 .borrow_mut()
647 .push("conversation_projection".to_string());
648 self.conversation.clone()
649 }
650
651 fn turn_handoff_projection(&self) -> TurnHandoffProjection {
652 self.calls
653 .borrow_mut()
654 .push("turn_handoff_projection".to_string());
655 self.turn.clone()
656 }
657
658 fn clipboard_requires_terminal_paste(&self) -> bool {
659 self.calls
660 .borrow_mut()
661 .push("clipboard_requires_terminal_paste".to_string());
662 self.terminal_paste
663 }
664
665 fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
666 self.calls
667 .borrow_mut()
668 .push(format!("write_recovery_copy({markdown})"));
669 self.recovery.clone()
670 }
671
672 fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
673 self.calls
674 .borrow_mut()
675 .push(format!("write_clipboard({markdown})"));
676 self.clipboard.clone()
677 }
678
679 fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
680 self.calls
681 .borrow_mut()
682 .push(format!("resolve_export_path({raw})"));
683 self.resolve.clone()
684 }
685
686 fn write_export_file(
687 &self,
688 path: &Path,
689 contents: &[u8],
690 force: bool,
691 ) -> Result<(), String> {
692 self.calls.borrow_mut().push(format!(
693 "write_export_file({}, {}, {force})",
694 path.display(),
695 String::from_utf8_lossy(contents)
696 ));
697 self.write.clone()
698 }
699 }
700
701 fn conversation_projection(messages: Vec<ExportMessage>) -> ConversationExportProjection {
702 ConversationExportProjection {
703 metadata: codewhale_command_contract::facets::ExportMetadata {
704 session_label: "sess12345678".to_string(),
705 provider: "deepseek".to_string(),
706 model: "deepseek-v4".to_string(),
707 mode: "agent".to_string(),
708 workspace_name: "workspace".to_string(),
709 message_count: messages.len(),
710 exported_at_unix: 1_700_000_000,
711 },
712 transcript: TranscriptProjection::Authoritative(messages),
713 restore_points: RestorePointProjection::None,
714 }
715 }
716
717 fn user_message(text: &str) -> ExportMessage {
718 ExportMessage {
719 is_user_role: true,
720 role: "user".to_string(),
721 blocks: vec![ExportBlock::Text {
722 text: text.to_string(),
723 }],
724 prompt_snippet: Some(text.to_string()),
725 }
726 }
727
728 fn snapshot(
729 id: &str,
730 label: &str,
731 timestamp: i64,
732 kind: &str,
733 sequence: Option<u64>,
734 ) -> RestoreSnapshot {
735 RestoreSnapshot {
736 id: id.to_string(),
737 label: label.to_string(),
738 timestamp_unix: timestamp,
739 kind: kind.to_string(),
740 sequence,
741 prompt_snippet: label
742 .split_once(": ")
743 .map(|(_, snippet)| snippet.to_string()),
744 }
745 }
746
747 #[test]
748 fn parser_matrix_is_exact() {
749 assert_eq!(
750 parse_request(None).unwrap(),
751 ExportRequest {
752 scope: ExportScope::Conversation,
753 destination: ExportDestination::Clipboard,
754 }
755 );
756 assert_eq!(
757 parse_request(Some("clipboard")).unwrap().scope,
758 ExportScope::Conversation
759 );
760 assert_eq!(
761 parse_request(Some("TURN")).unwrap().scope,
762 ExportScope::Turn
763 );
764 assert_eq!(
765 parse_request(Some("file --force reports/chat export.md")).unwrap(),
766 ExportRequest {
767 scope: ExportScope::Conversation,
768 destination: ExportDestination::File {
769 path: "reports/chat export.md".to_string(),
770 force: true,
771 },
772 }
773 );
774 assert_eq!(
775 parse_request(Some("legacy export.md")).unwrap(),
776 ExportRequest {
777 scope: ExportScope::Conversation,
778 destination: ExportDestination::File {
779 path: "legacy export.md".to_string(),
780 force: false,
781 },
782 }
783 );
784 assert_eq!(
785 parse_request(Some("turn file --force handoff.md")).unwrap(),
786 ExportRequest {
787 scope: ExportScope::Turn,
788 destination: ExportDestination::File {
789 path: "handoff.md".to_string(),
790 force: true,
791 },
792 }
793 );
794 assert_eq!(
795 parse_request(Some("turn legacy.md")).unwrap(),
796 ExportRequest {
797 scope: ExportScope::Turn,
798 destination: ExportDestination::File {
799 path: "legacy.md".to_string(),
800 force: false,
801 },
802 }
803 );
804 assert_eq!(
805 parse_request(Some("turn clipboard")).unwrap().destination,
806 ExportDestination::Clipboard
807 );
808
809 for arg in [
810 "file",
811 "file --force",
812 "clipboard extra.md",
813 "turn clipboard extra.md",
814 ] {
815 assert!(parse_request(Some(arg)).is_err(), "{arg}");
816 }
817 assert_eq!(
818 parse_request(Some("file")).unwrap_err(),
819 export_usage("missing file path")
820 );
821 assert_eq!(
822 parse_request(Some("clipboard extra.md")).unwrap_err(),
823 export_usage("clipboard does not accept a path")
824 );
825 }
826
827 #[test]
828 fn missing_authority_fails_safely_without_effects() {
829 let result = export_contextual(CommandContexts::empty(), Some("clipboard"));
830 assert!(result.is_error);
831 assert_eq!(
832 result.message.as_deref(),
833 Some("Error: Command capability unavailable: session_export")
834 );
835 }
836
837 #[test]
838 fn conversation_clipboard_renders_full_document_and_sequences_operations() {
839 let fake = FakeExport {
840 conversation: conversation_projection(vec![
841 ExportMessage {
842 is_user_role: false,
843 role: "system".to_string(),
844 blocks: vec![ExportBlock::Text {
845 text: "hidden policy must never export".to_string(),
846 }],
847 prompt_snippet: None,
848 },
849 user_message("Please inspect this"),
850 ]),
851 recovery: Some(PathBuf::from("/home/.codewhale/exports/last-copy.md")),
852 ..FakeExport::default()
853 };
854
855 let result = export_portable(&fake, Some("clipboard"));
856
857 assert!(!result.is_error, "{:?}", result.message);
858 let message = result.message.as_deref().unwrap_or_default();
859 assert!(
860 message.starts_with("Conversation copied to the local clipboard ("),
861 "{message}"
862 );
863 assert!(
864 message.contains(" lines; a terminal clipboard fallback may have been used)"),
865 "{message}"
866 );
867 assert!(
868 message.ends_with("; a copy is at /home/.codewhale/exports/last-copy.md"),
869 "{message}"
870 );
871 let read = fake.calls.borrow();
872 assert_eq!(read[0], "conversation_projection");
873 assert_eq!(read[1], "clipboard_requires_terminal_paste");
874 assert!(read[2].starts_with("write_recovery_copy("), "{read:?}");
875 assert!(read[3].starts_with("write_clipboard("), "{read:?}");
876 assert_eq!(read.len(), 4, "exactly one recovery and one clipboard call");
877 let recovery_payload = read[2]
878 .trim_start_matches("write_recovery_copy(")
879 .trim_end_matches(')');
880 let markdown = read[3]
881 .trim_start_matches("write_clipboard(")
882 .trim_end_matches(')');
883 assert_eq!(
884 recovery_payload, markdown,
885 "both writes receive identical Markdown"
886 );
887 assert!(markdown.starts_with("# Codewhale conversation export\n\n"));
888 assert!(markdown.contains("## 1. system\n\n[internal context omitted]\n\n"));
889 assert!(markdown.contains("## 2. user\n\n### Content 1: Text\n\nPlease inspect this\n\n"));
890 assert!(!markdown.contains("hidden policy must never export"));
891 }
892
893 #[test]
894 fn conversation_document_matches_full_golden_equality() {
895 let projection = conversation_projection(vec![
896 ExportMessage {
897 is_user_role: false,
898 role: "system".to_string(),
899 blocks: vec![ExportBlock::Text {
900 text: "hidden policy must never export".to_string(),
901 }],
902 prompt_snippet: None,
903 },
904 user_message("Please inspect this"),
905 ]);
906
907 let expected = "# Codewhale conversation export\n\n\
908 - Exported: 2023-11-14T22:13:20Z\n\
909 - Session: sess12345678\n\
910 - Provider: deepseek\n\
911 - Model: deepseek-v4\n\
912 - Mode: agent\n\
913 - Workspace: workspace\n\
914 - Messages: 2\n\n\
915 > Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.\n\n\
916 ## Restore points\n\n\
917 No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.\n\n\
918 ## 1. system\n\n[internal context omitted]\n\n\
919 ## 2. user\n\n### Content 1: Text\n\nPlease inspect this\n\n";
920
921 assert_eq!(render_conversation(projection), expected);
922 }
923
924 #[test]
925 fn ssh_clipboard_uses_terminal_client_wording() {
926 let fake = FakeExport {
927 terminal_paste: true,
928 conversation: conversation_projection(vec![user_message("hi")]),
929 ..FakeExport::default()
930 };
931
932 let result = export_portable(&fake, Some("clipboard"));
933
934 assert!(!result.is_error);
935 let message = result.message.as_deref().unwrap_or_default();
936 assert!(
937 message.contains("terminal-client clipboard over SSH via tmux/OSC 52"),
938 "{message}"
939 );
940 assert!(
941 !message.contains("a copy is at"),
942 "no recovery path present: {message}"
943 );
944 }
945
946 #[test]
947 fn recovery_failure_still_attempts_clipboard() {
948 let fake = FakeExport {
949 conversation: conversation_projection(vec![user_message("hi")]),
950 recovery: None,
951 clipboard: Err("no clipboard".to_string()),
952 ..FakeExport::default()
953 };
954
955 let result = export_portable(&fake, Some("clipboard"));
956
957 assert!(result.is_error);
958 assert_eq!(
959 result.message.as_deref(),
960 Some(
961 "Error: Clipboard export failed: no clipboard. No file was written; use `/export file <path>` to choose an explicit destination"
962 )
963 );
964 let read = fake.calls.borrow();
965 assert!(
966 read.iter()
967 .any(|call| call.starts_with("write_recovery_copy("))
968 );
969 assert!(read.iter().any(|call| call.starts_with("write_clipboard(")));
970 }
971
972 #[test]
973 fn file_export_renders_resolves_then_writes_once() {
974 let fake = FakeExport {
975 conversation: conversation_projection(vec![user_message("first export")]),
976 resolve: Ok(PathBuf::from("/workspace/transcript.md")),
977 write: Ok(()),
978 ..FakeExport::default()
979 };
980
981 let result = export_portable(&fake, Some("file transcript.md"));
982
983 assert!(!result.is_error, "{:?}", result.message);
984 assert_eq!(
985 result.message.as_deref(),
986 Some("Conversation exported to /workspace/transcript.md")
987 );
988 let read = fake.calls.borrow();
989 assert_eq!(read.len(), 3, "{read:?}");
990 assert_eq!(read[0], "conversation_projection");
991 assert_eq!(read[1], "resolve_export_path(transcript.md)");
992 assert!(read[2].starts_with("write_export_file(/workspace/transcript.md,"));
993 assert!(
994 !read
995 .iter()
996 .any(|call| call.contains("clipboard") || call.contains("recovery")),
997 "file export must not touch clipboard or recovery: {read:?}"
998 );
999 }
1000
1001 #[test]
1002 fn resolution_failure_prevents_writing() {
1003 let fake = FakeExport {
1004 conversation: conversation_projection(vec![user_message("x")]),
1005 resolve: Err("export paths may not contain `..`".to_string()),
1006 ..FakeExport::default()
1007 };
1008
1009 let result = export_portable(&fake, Some("file ../escape.md"));
1010
1011 assert!(result.is_error);
1012 assert_eq!(
1013 result.message.as_deref(),
1014 Some("Error: export paths may not contain `..`")
1015 );
1016 let read = fake.calls.borrow();
1017 assert!(
1018 !read
1019 .iter()
1020 .any(|call| call.starts_with("write_export_file(")),
1021 "no write after resolution failure: {read:?}"
1022 );
1023 }
1024
1025 #[test]
1026 fn write_failure_wraps_exact_baseline_text() {
1027 let fake = FakeExport {
1028 conversation: conversation_projection(vec![user_message("x")]),
1029 resolve: Ok(PathBuf::from("/workspace/out.md")),
1030 write: Err("destination already exists: /workspace/out.md".to_string()),
1031 ..FakeExport::default()
1032 };
1033
1034 let result = export_portable(&fake, Some("file out.md"));
1035
1036 assert!(result.is_error);
1037 assert_eq!(
1038 result.message.as_deref(),
1039 Some(
1040 "Error: Failed to export Conversation to /workspace/out.md: destination already exists: /workspace/out.md"
1041 )
1042 );
1043 }
1044
1045 #[test]
1046 fn forced_file_export_reports_overwrite_suffix() {
1047 let fake = FakeExport {
1048 conversation: conversation_projection(vec![user_message("x")]),
1049 resolve: Ok(PathBuf::from("/workspace/out.md")),
1050 ..FakeExport::default()
1051 };
1052
1053 let result = export_portable(&fake, Some("file --force out.md"));
1054
1055 assert_eq!(
1056 result.message.as_deref(),
1057 Some("Conversation exported to /workspace/out.md (overwrite explicitly allowed)")
1058 );
1059 let read = fake.calls.borrow();
1060 assert!(read[2].ends_with(", true)"), "{:?}", read[2]);
1061 }
1062
1063 #[test]
1064 fn turn_export_sanitizes_then_replaces_nonempty_workspace() {
1065 let fake = FakeExport {
1066 turn: TurnHandoffProjection {
1067 markdown: "# Turn handoff\n\n\u{1b}[31m/Users/me/repo\u{1b}[0m done".to_string(),
1068 workspace_path: "/Users/me/repo".to_string(),
1069 },
1070 ..FakeExport::default()
1071 };
1072
1073 let result = export_portable(&fake, Some("turn"));
1074
1075 assert!(!result.is_error);
1076 let read = fake.calls.borrow();
1077 let markdown = read
1078 .iter()
1079 .find_map(|call| {
1080 call.strip_prefix("write_clipboard(")
1081 .map(|c| c.trim_end_matches(')'))
1082 })
1083 .expect("clipboard payload");
1084 assert_eq!(markdown, "# Turn handoff\n\n. done");
1085 assert!(
1086 !read
1087 .iter()
1088 .any(|call| call.starts_with("conversation_projection")),
1089 "turn-only export must not read conversation snapshots: {read:?}"
1090 );
1091 }
1092
1093 #[test]
1094 fn turn_export_with_empty_workspace_path_skips_replacement() {
1095 let fake = FakeExport {
1096 turn: TurnHandoffProjection {
1097 markdown: "path stays".to_string(),
1098 workspace_path: String::new(),
1099 },
1100 ..FakeExport::default()
1101 };
1102 assert_eq!(sanitize_turn_handoff(&fake.turn), "path stays");
1103 }
1104
1105 #[test]
1106 fn restore_summary_distinguishes_every_state() {
1107 let mut none = String::new();
1108 render_restore_summary(&mut none, &RestorePointProjection::None);
1109 assert!(
1110 none.contains("No workspace restore points are recorded"),
1111 "{none}"
1112 );
1113
1114 let mut unreadable = String::new();
1115 render_restore_summary(
1116 &mut unreadable,
1117 &RestorePointProjection::Unreadable {
1118 reason: "permission denied".to_string(),
1119 },
1120 );
1121 assert!(unreadable.contains("could not be read"), "{unreadable}");
1122 assert!(
1123 unreadable.contains("unavailable rather than empty"),
1124 "{unreadable}"
1125 );
1126
1127 let mut empty = String::new();
1128 render_restore_summary(
1129 &mut empty,
1130 &RestorePointProjection::Recorded {
1131 snapshots: Vec::new(),
1132 },
1133 );
1134 assert!(empty.contains("records no restore points yet"), "{empty}");
1135
1136 let mut recorded = String::new();
1137 render_restore_summary(
1138 &mut recorded,
1139 &RestorePointProjection::Recorded {
1140 snapshots: vec![
1141 snapshot(
1142 &"a".repeat(40),
1143 "pre-turn:2: second prompt",
1144 1_700_000_100,
1145 "pre-turn",
1146 Some(2),
1147 ),
1148 snapshot(
1149 &"b".repeat(40),
1150 "pre-turn:1: first prompt",
1151 1_700_000_000,
1152 "pre-turn",
1153 Some(1),
1154 ),
1155 ],
1156 },
1157 );
1158 assert!(
1159 recorded.contains(
1160 "| 1 | `aaaaaaaaaaaa` | 2023-11-14T22:15:00Z | pre-turn:2: second prompt |"
1161 ),
1162 "{recorded}"
1163 );
1164 assert!(
1165 recorded.contains(
1166 "| 2 | `bbbbbbbbbbbb` | 2023-11-14T22:13:20Z | pre-turn:1: first prompt |"
1167 ),
1168 "{recorded}"
1169 );
1170 }
1171
1172 #[test]
1173 fn correlation_matches_ambiguity_absence_and_role_rules() {
1174 let recorded = RestorePointProjection::Recorded {
1175 snapshots: vec![
1176 snapshot(
1177 &"e".repeat(40),
1178 "pre-turn:9: run the tests",
1179 1_700_000_300,
1180 "pre-turn",
1181 Some(9),
1182 ),
1183 snapshot(
1184 &"f".repeat(40),
1185 "pre-turn:5: run the tests",
1186 1_700_000_100,
1187 "pre-turn",
1188 Some(5),
1189 ),
1190 snapshot(
1191 &"2".repeat(40),
1192 "tool:call_abc: rename the widget",
1193 1_700_000_000,
1194 "tool",
1195 None,
1196 ),
1197 ],
1198 };
1199
1200 let ambiguous = correlation_markdown(&recorded, &user_message("run the tests"));
1201 assert!(
1202 ambiguous.contains("N1 `eeeeeeeeeeee` (pre-turn turn 9)"),
1203 "{ambiguous}"
1204 );
1205 assert!(
1206 ambiguous.contains("N2 `ffffffffffff` (pre-turn turn 5)"),
1207 "{ambiguous}"
1208 );
1209 assert!(ambiguous.contains("ambiguous"), "{ambiguous}");
1210
1211 let none = correlation_markdown(&recorded, &user_message("never snapshotted"));
1212 assert!(none.contains("none recorded for this message"), "{none}");
1213
1214 let tool_only = correlation_markdown(&recorded, &user_message("rename the widget"));
1215 assert!(
1216 tool_only.contains("none recorded for this message"),
1217 "{tool_only}"
1218 );
1219
1220 let assistant = correlation_markdown(
1221 &recorded,
1222 &ExportMessage {
1223 is_user_role: false,
1224 role: "assistant".to_string(),
1225 blocks: vec![ExportBlock::Text {
1226 text: "run the tests".to_string(),
1227 }],
1228 prompt_snippet: Some("run the tests".to_string()),
1229 },
1230 );
1231 assert!(assistant.is_empty(), "{assistant}");
1232 }
1233
1234 #[test]
1235 fn correlation_requires_exact_user_identity_not_the_role_string() {
1236 let recorded = RestorePointProjection::Recorded {
1237 snapshots: vec![snapshot(
1238 "aaaaaaaaaaaa",
1239 "pre-turn:4: run the tests",
1240 5,
1241 "pre-turn",
1242 Some(4),
1243 )],
1244 };
1245 // Same rendered role string as a real user turn, but not `Role::User`.
1246 let unrecognized = ExportMessage {
1247 role: "user".to_string(),
1248 is_user_role: false,
1249 blocks: vec![ExportBlock::Text {
1250 text: "run the tests".to_string(),
1251 }],
1252 prompt_snippet: Some("run the tests".to_string()),
1253 };
1254 assert!(
1255 correlation_markdown(&recorded, &unrecognized).is_empty(),
1256 "a role that only renders as \"user\" must not correlate"
1257 );
1258
1259 let real_user = ExportMessage {
1260 is_user_role: true,
1261 ..unrecognized
1262 };
1263 assert!(
1264 correlation_markdown(&recorded, &real_user).contains("N1 `aaaaaaaaaaaa`"),
1265 "an exact user turn still correlates"
1266 );
1267 }
1268
1269 #[test]
1270 fn history_fallback_marks_literals_and_sanitizes_visible_bodies() {
1271 let mut out = String::new();
1272 render_history_fallback(
1273 &mut out,
1274 &[
1275 HistoryEntry::Sanitized {
1276 role: "user".to_string(),
1277 body: "hello\u{1b}[31m world".to_string(),
1278 },
1279 HistoryEntry::Literal {
1280 role: "system".to_string(),
1281 body: "[internal context omitted]".to_string(),
1282 },
1283 ],
1284 );
1285 assert!(out.contains("## 1. user\n\nhello world\n\n"), "{out}");
1286 assert!(
1287 out.contains("## 2. system\n\n[internal context omitted]\n\n"),
1288 "{out}"
1289 );
1290
1291 let mut empty = String::new();
1292 render_history_fallback(&mut empty, &[]);
1293 assert_eq!(empty, "## Conversation\n\n[empty conversation]\n");
1294 }
1295
1296 #[test]
1297 fn json_fence_tracks_longest_backtick_run_and_redacts_secrets() {
1298 let mut out = String::new();
1299 push_json(
1300 &mut out,
1301 serde_json::json!({"api_key": "literal-secret", "note": "``` inner"}),
1302 );
1303 assert!(!out.contains("literal-secret"), "{out}");
1304 assert!(out.starts_with("````json\n"), "{out}");
1305 assert!(out.contains("\"api_key\": \"[redacted]\""), "{out}");
1306 }
1307
1308 #[test]
1309 fn empty_content_and_missing_blocks_use_baseline_markers() {
1310 let mut out = String::new();
1311 render_message(
1312 &mut out,
1313 1,
1314 ExportMessage {
1315 is_user_role: false,
1316 role: "assistant".to_string(),
1317 blocks: Vec::new(),
1318 prompt_snippet: None,
1319 },
1320 );
1321 assert!(out.contains("## 1. assistant\n\n[no content]\n\n"), "{out}");
1322
1323 let mut empty_text = String::new();
1324 render_content_block(
1325 &mut empty_text,
1326 1,
1327 ExportBlock::Text {
1328 text: " ".to_string(),
1329 },
1330 );
1331 assert!(empty_text.ends_with("[empty text]\n\n"), "{empty_text}");
1332 }
1333
1334 #[test]
1335 fn parser_handles_whitespace_case_and_only_leading_force() {
1336 // Surrounding whitespace is trimmed, keyword matching is ASCII
1337 // case-insensitive, and only a leading `--force` is honored (the
1338 // baseline `strip_word` semantics).
1339 assert_eq!(
1340 parse_request(Some(" clipboard ")).unwrap(),
1341 ExportRequest {
1342 scope: ExportScope::Conversation,
1343 destination: ExportDestination::Clipboard,
1344 }
1345 );
1346 assert_eq!(
1347 parse_request(Some(" TURN ")).unwrap(),
1348 ExportRequest {
1349 scope: ExportScope::Turn,
1350 destination: ExportDestination::Clipboard,
1351 }
1352 );
1353 assert_eq!(
1354 parse_request(Some(" File Report One.md ")).unwrap(),
1355 ExportRequest {
1356 scope: ExportScope::Conversation,
1357 destination: ExportDestination::File {
1358 path: "Report One.md".to_string(),
1359 force: false,
1360 },
1361 }
1362 );
1363 assert_eq!(
1364 parse_request(Some("turn FILE --Force handoff.md")).unwrap(),
1365 ExportRequest {
1366 scope: ExportScope::Turn,
1367 destination: ExportDestination::File {
1368 path: "handoff.md".to_string(),
1369 force: true,
1370 },
1371 }
1372 );
1373 // A trailing `--force` is not a force flag; it becomes part of the
1374 // literal path exactly like the baseline parser.
1375 assert_eq!(
1376 parse_request(Some("file out.md --force")).unwrap(),
1377 ExportRequest {
1378 scope: ExportScope::Conversation,
1379 destination: ExportDestination::File {
1380 path: "out.md --force".to_string(),
1381 force: false,
1382 },
1383 }
1384 );
1385 // The turn branch keeps the same usage errors as the conversation one.
1386 assert_eq!(
1387 parse_request(Some("turn file")).unwrap_err(),
1388 export_usage("missing file path")
1389 );
1390 assert_eq!(
1391 parse_request(Some("turn file --force")).unwrap_err(),
1392 export_usage("missing file path")
1393 );
1394 assert_eq!(
1395 parse_request(Some("turn clipboard extra.md")).unwrap_err(),
1396 export_usage("clipboard does not accept a path")
1397 );
1398 }
1399
1400 #[test]
1401 fn header_metadata_keeps_unsaved_and_workspace_fallbacks() {
1402 let projection = ConversationExportProjection {
1403 metadata: codewhale_command_contract::facets::ExportMetadata {
1404 session_label: "unsaved".to_string(),
1405 provider: "unknown".to_string(),
1406 model: "unknown".to_string(),
1407 mode: "agent".to_string(),
1408 workspace_name: "workspace".to_string(),
1409 message_count: 0,
1410 exported_at_unix: 0,
1411 },
1412 transcript: TranscriptProjection::Authoritative(Vec::new()),
1413 restore_points: RestorePointProjection::None,
1414 };
1415
1416 let rendered = render_conversation(projection);
1417
1418 assert!(
1419 rendered.contains("- Exported: 1970-01-01T00:00:00Z\n"),
1420 "{rendered}"
1421 );
1422 assert!(rendered.contains("- Session: unsaved\n"), "{rendered}");
1423 assert!(rendered.contains("- Provider: unknown\n"), "{rendered}");
1424 assert!(rendered.contains("- Model: unknown\n"), "{rendered}");
1425 assert!(rendered.contains("- Workspace: workspace\n"), "{rendered}");
1426 assert!(rendered.contains("- Messages: 0\n"), "{rendered}");
1427 }
1428
1429 #[test]
1430 fn every_content_block_variant_renders_exactly() {
1431 // Image reference: URL credentials and sensitive query values are masked.
1432 let mut image = String::new();
1433 render_content_block(
1434 &mut image,
1435 1,
1436 ExportBlock::ImageReference {
1437 url: "https://alice:pw@example.com/a.png?api_key=hidden&ok=1".to_string(),
1438 },
1439 );
1440 assert_eq!(
1441 image,
1442 "### Content 1: Image attachment\n\n- Reference: https://***:***@example.com/a.png?api_key=***&ok=1\n\n"
1443 );
1444
1445 // Inline/local image payloads became an omission marker at projection.
1446 let mut omitted = String::new();
1447 render_content_block(&mut omitted, 2, ExportBlock::ImageOmitted);
1448 assert_eq!(
1449 omitted,
1450 "### Content 2: Image attachment\n\n- Reference omitted (inline or local image payload)\n\n"
1451 );
1452
1453 // Reasoning bodies and signatures are replaced by the baseline marker.
1454 let mut reasoning = String::new();
1455 render_content_block(&mut reasoning, 3, ExportBlock::InternalReasoning);
1456 assert_eq!(
1457 reasoning,
1458 "### Content 3: Internal reasoning\n\n[internal reasoning and signature omitted]\n\n"
1459 );
1460
1461 // Tool call with caller metadata and redacted JSON input.
1462 let mut tool_call = String::new();
1463 render_content_block(
1464 &mut tool_call,
1465 4,
1466 ExportBlock::ToolCall {
1467 id: "call-1".to_string(),
1468 name: "fetch_url".to_string(),
1469 caller: Some(codewhale_command_contract::facets::ToolCallerProjection {
1470 caller_type: "code_execution_20250825".to_string(),
1471 tool_id: Some("server-tool-1".to_string()),
1472 }),
1473 input: serde_json::json!({"api_key": "literal-secret"}),
1474 },
1475 );
1476 assert_eq!(
1477 tool_call,
1478 "### Content 4: Tool call\n\n- ID: call-1\n- Name: fetch_url\n- Caller type: code_execution_20250825\n- Caller tool ID: server-tool-1\n\nInput:\n\n```json\n{\n \"api_key\": \"[redacted]\"\n}\n```\n\n"
1479 );
1480
1481 // Tool call without caller metadata omits only the caller lines.
1482 let mut bare_tool_call = String::new();
1483 render_content_block(
1484 &mut bare_tool_call,
1485 5,
1486 ExportBlock::ToolCall {
1487 id: "call-2".to_string(),
1488 name: "read_file".to_string(),
1489 caller: None,
1490 input: serde_json::json!({}),
1491 },
1492 );
1493 assert_eq!(
1494 bare_tool_call,
1495 "### Content 5: Tool call\n\n- ID: call-2\n- Name: read_file\n\nInput:\n\n```json\n{}\n```\n\n"
1496 );
1497
1498 // Tool result with structured blocks: both the sanitized result text
1499 // and the redacted structured payload are rendered.
1500 let mut structured_result = String::new();
1501 render_content_block(
1502 &mut structured_result,
1503 6,
1504 ExportBlock::ToolResult {
1505 tool_use_id: "call-1".to_string(),
1506 content: "tool output line".to_string(),
1507 is_error: false,
1508 structured: Some(serde_json::json!([{"session_token": "session-secret"}])),
1509 },
1510 );
1511 assert_eq!(
1512 structured_result,
1513 "### Content 6: Tool result\n\n- Tool call ID: call-1\n- Error: false\n\nResult:\n\ntool output line\n\nStructured result blocks:\n\n```json\n[\n {\n \"session_token\": \"[redacted]\"\n }\n]\n```\n\n"
1514 );
1515
1516 // Tool result without structured blocks ends after the result text, and
1517 // an empty body keeps the baseline empty marker.
1518 let mut plain_result = String::new();
1519 render_content_block(
1520 &mut plain_result,
1521 7,
1522 ExportBlock::ToolResult {
1523 tool_use_id: "call-2".to_string(),
1524 content: String::new(),
1525 is_error: true,
1526 structured: None,
1527 },
1528 );
1529 assert_eq!(
1530 plain_result,
1531 "### Content 7: Tool result\n\n- Tool call ID: call-2\n- Error: true\n\nResult:\n\n[empty text]\n\n"
1532 );
1533
1534 let mut server_call = String::new();
1535 render_content_block(
1536 &mut server_call,
1537 8,
1538 ExportBlock::ServerToolCall {
1539 id: "srv-1".to_string(),
1540 name: "web_search".to_string(),
1541 input: serde_json::json!({"query": "rust"}),
1542 },
1543 );
1544 assert_eq!(
1545 server_call,
1546 "### Content 8: Server tool call\n\n- ID: srv-1\n- Name: web_search\n\nInput:\n\n```json\n{\n \"query\": \"rust\"\n}\n```\n\n"
1547 );
1548
1549 let mut search_result = String::new();
1550 render_content_block(
1551 &mut search_result,
1552 9,
1553 ExportBlock::ToolSearchResult {
1554 tool_use_id: "search-1".to_string(),
1555 content: serde_json::json!({"results": []}),
1556 },
1557 );
1558 assert_eq!(
1559 search_result,
1560 "### Content 9: Tool-search result\n\n- Tool call ID: search-1\n\n```json\n{\n \"results\": []\n}\n```\n\n"
1561 );
1562
1563 let mut execution_result = String::new();
1564 render_content_block(
1565 &mut execution_result,
1566 10,
1567 ExportBlock::CodeExecutionResult {
1568 tool_use_id: "exec-1".to_string(),
1569 content: serde_json::json!({"stdout": "ok"}),
1570 },
1571 );
1572 assert_eq!(
1573 execution_result,
1574 "### Content 10: Code-execution result\n\n- Tool call ID: exec-1\n\n```json\n{\n \"stdout\": \"ok\"\n}\n```\n\n"
1575 );
1576 }
1577 }
1578
1578 lines RUST