返回 CodeWhale
export.rs
根目录 / crates / tui / src / commands / groups / session / export.rs
1 //! `/export` command.
2 //!
3 //! The full-conversation export is a projection of the authoritative API
4 //! message stream. It deliberately omits hidden reasoning and signed-thinking
5 //! payloads, redacts secret-shaped values, and never mutates session or Work
6 //! state.
7
8 use std::fmt::Write as FmtWrite;
9 use std::fs::{self, OpenOptions};
10 use std::io::Write as IoWrite;
11 use std::path::{Component, Path, PathBuf};
12 use std::sync::OnceLock;
13
14 use regex::Regex;
15 use serde_json::Value;
16
17 use crate::commands::traits::{CommandInfo, RegisterCommand};
18 use crate::localization::MessageId;
19 use crate::models::{ContentBlock, Message};
20 use crate::tui::app::App;
21 use crate::tui::history::HistoryCell;
22
23 use super::CommandResult;
24
25 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
26 name: "export",
27 aliases: &["daochu"],
28 usage: "/export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]",
29 description_id: MessageId::CmdExportDescription,
30 };
31
32 pub(in crate::commands) struct ExportCmd;
33
34 impl RegisterCommand for ExportCmd {
35 fn info() -> &'static CommandInfo {
36 &COMMAND_INFO
37 }
38
39 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
40 execute_export(app, arg)
41 }
42 }
43
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 enum ExportScope {
46 Conversation,
47 Turn,
48 }
49
50 #[derive(Debug, Clone, PartialEq, Eq)]
51 enum ExportDestination {
52 Clipboard,
53 File { path: String, force: bool },
54 }
55
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 struct ExportRequest {
58 scope: ExportScope,
59 destination: ExportDestination,
60 }
61
62 fn execute_export(app: &mut App, arg: Option<&str>) -> CommandResult {
63 let request = match parse_request(arg) {
64 Ok(request) => request,
65 Err(err) => return CommandResult::error(err),
66 };
67 let label = match request.scope {
68 ExportScope::Conversation => "Conversation",
69 ExportScope::Turn => "Turn handoff",
70 };
71 let markdown = match request.scope {
72 ExportScope::Conversation => render_conversation(app),
73 ExportScope::Turn => {
74 let rendered = crate::tui::ui::turn_handoff_markdown(app);
75 sanitize_turn_handoff(app, &rendered)
76 }
77 };
78
79 match request.destination {
80 ExportDestination::Clipboard => copy_to_clipboard(app, label, &markdown),
81 ExportDestination::File { path, force } => {
82 let path = match resolve_export_path(&app.workspace, &path) {
83 Ok(path) => path,
84 Err(err) => return CommandResult::error(err),
85 };
86 match write_export_file(&path, markdown.as_bytes(), force) {
87 Ok(()) => CommandResult::message(format!(
88 "{label} exported to {}{}",
89 path.display(),
90 if force {
91 " (overwrite explicitly allowed)"
92 } else {
93 ""
94 }
95 )),
96 Err(err) => CommandResult::error(format!(
97 "Failed to export {label} to {}: {err}",
98 path.display()
99 )),
100 }
101 }
102 }
103 }
104
105 fn parse_request(arg: Option<&str>) -> Result<ExportRequest, String> {
106 let raw = arg.unwrap_or("").trim();
107 if raw.is_empty() || raw.eq_ignore_ascii_case("clipboard") {
108 return Ok(ExportRequest {
109 scope: ExportScope::Conversation,
110 destination: ExportDestination::Clipboard,
111 });
112 }
113
114 if raw.eq_ignore_ascii_case("turn") {
115 return Ok(ExportRequest {
116 scope: ExportScope::Turn,
117 destination: ExportDestination::Clipboard,
118 });
119 }
120
121 if let Some(rest) = strip_word(raw, "turn") {
122 let rest = rest.trim();
123 if rest.is_empty() || rest.eq_ignore_ascii_case("clipboard") {
124 return Ok(ExportRequest {
125 scope: ExportScope::Turn,
126 destination: ExportDestination::Clipboard,
127 });
128 }
129 let destination = if let Some(file_args) = strip_word(rest, "file") {
130 parse_file_destination(file_args)?
131 } else if rest.eq_ignore_ascii_case("file") {
132 return Err(export_usage("missing file path"));
133 } else if strip_word(rest, "clipboard").is_some() {
134 return Err(export_usage("clipboard does not accept a path"));
135 } else {
136 // Backward compatibility: `/export turn <path>`.
137 ExportDestination::File {
138 path: rest.to_string(),
139 force: false,
140 }
141 };
142 return Ok(ExportRequest {
143 scope: ExportScope::Turn,
144 destination,
145 });
146 }
147
148 if let Some(file_args) = strip_word(raw, "file") {
149 return Ok(ExportRequest {
150 scope: ExportScope::Conversation,
151 destination: parse_file_destination(file_args)?,
152 });
153 }
154 if raw.eq_ignore_ascii_case("file") {
155 return Err(export_usage("missing file path"));
156 }
157 if strip_word(raw, "clipboard").is_some() {
158 return Err(export_usage("clipboard does not accept a path"));
159 }
160
161 // Backward compatibility: `/export <path>`.
162 Ok(ExportRequest {
163 scope: ExportScope::Conversation,
164 destination: ExportDestination::File {
165 path: raw.to_string(),
166 force: false,
167 },
168 })
169 }
170
171 fn parse_file_destination(raw: &str) -> Result<ExportDestination, String> {
172 let trimmed = raw.trim();
173 let (force, path) = if let Some(path) = strip_word(trimmed, "--force") {
174 (true, path.trim())
175 } else if trimmed.eq_ignore_ascii_case("--force") {
176 (true, "")
177 } else {
178 (false, trimmed)
179 };
180 if path.is_empty() {
181 return Err(export_usage("missing file path"));
182 }
183 Ok(ExportDestination::File {
184 path: path.to_string(),
185 force,
186 })
187 }
188
189 fn strip_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
190 let prefix = value.get(..word.len())?;
191 if !prefix.eq_ignore_ascii_case(word) {
192 return None;
193 }
194 let rest = value.get(word.len()..)?;
195 rest.chars()
196 .next()
197 .is_some_and(char::is_whitespace)
198 .then_some(rest)
199 }
200
201 fn export_usage(reason: &str) -> String {
202 format!(
203 "{reason}. Usage: /export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]"
204 )
205 }
206
207 fn copy_to_clipboard(app: &mut App, label: &str, markdown: &str) -> CommandResult {
208 let terminal_client = app.clipboard.requires_terminal_paste();
209 match app.clipboard.write_text(markdown) {
210 Ok(()) if terminal_client => CommandResult::message(format!(
211 "{label} sent to the terminal-client clipboard over SSH via tmux/OSC 52 ({} lines); terminal support and settings determine whether the client accepts it",
212 markdown.lines().count()
213 )),
214 Ok(()) => CommandResult::message(format!(
215 "{label} copied to the local clipboard ({} lines; a terminal clipboard fallback may have been used)",
216 markdown.lines().count()
217 )),
218 Err(err) => CommandResult::error(format!(
219 "Clipboard export failed: {err}. No file was written; use `/export file <path>` to choose an explicit destination"
220 )),
221 }
222 }
223
224 fn render_conversation(app: &App) -> String {
225 let message_count = if app.api_messages.is_empty() {
226 app.history.len()
227 } else {
228 app.api_messages.len()
229 };
230 let mut out = String::new();
231 out.push_str("# Codewhale conversation export\n\n");
232 let _ = writeln!(
233 out,
234 "- Exported: {}",
235 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
236 );
237 let session = app
238 .current_session_id
239 .as_deref()
240 .map(crate::session_manager::truncate_id)
241 .unwrap_or("unsaved");
242 let _ = writeln!(out, "- Session: {}", inline_text(session));
243 let _ = writeln!(
244 out,
245 "- Provider: {}",
246 inline_text(app.provider_identity_for_persistence())
247 );
248 let _ = writeln!(out, "- Model: {}", inline_text(&app.model_display_label()));
249 let _ = writeln!(out, "- Mode: {}", app.mode.display_name());
250 let workspace_name = app
251 .workspace
252 .file_name()
253 .and_then(|name| name.to_str())
254 .unwrap_or("workspace");
255 let _ = writeln!(out, "- Workspace: {}", inline_text(workspace_name));
256 let _ = writeln!(out, "- Messages: {message_count}");
257 out.push_str(
258 "\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",
259 );
260
261 let restore_points = RestorePoints::read(&app.workspace);
262 restore_points.render_summary(&mut out);
263
264 if app.api_messages.is_empty() {
265 render_history_fallback(&mut out, &app.history);
266 } else {
267 for (index, message) in app.api_messages.iter().enumerate() {
268 render_message(&mut out, index + 1, message);
269 restore_points.render_correlation(&mut out, message);
270 }
271 }
272 out
273 }
274
275 /// Maximum restore points listed in the export summary. The side-git repo is
276 /// already capped, but the export is a document a person reads, so it gets its
277 /// own bound rather than inheriting whatever the repo happens to hold.
278 const RESTORE_POINT_SUMMARY_MAX: usize = 100;
279
280 /// Characters of the snapshot SHA shown as a restore-point id.
281 const RESTORE_POINT_ID_LEN: usize = 12;
282
283 /// Restore points (side-git workspace snapshots) recorded for this workspace,
284 /// read read-only so `/export` never creates a snapshot repo as a side effect.
285 enum RestorePoints {
286 /// The repo exists and these are its most recent snapshots (newest first).
287 Recorded(Vec<crate::snapshot::Snapshot>),
288 /// No snapshot repo exists for this workspace.
289 None,
290 /// The repo exists but could not be read. The reason is reported rather
291 /// than swallowed — a silent omission would read as "no restore points".
292 Unreadable(String),
293 }
294
295 impl RestorePoints {
296 fn read(workspace: &Path) -> Self {
297 match crate::snapshot::SnapshotRepo::open_existing(workspace) {
298 Ok(None) => Self::None,
299 Err(err) => Self::Unreadable(err.to_string()),
300 Ok(Some(repo)) => match repo.list(RESTORE_POINT_SUMMARY_MAX) {
301 Ok(snapshots) => Self::Recorded(snapshots),
302 Err(err) => Self::Unreadable(err.to_string()),
303 },
304 }
305 }
306
307 fn render_summary(&self, out: &mut String) {
308 out.push_str("## Restore points\n\n");
309 match self {
310 Self::None => {
311 out.push_str(
312 "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",
313 );
314 }
315 Self::Unreadable(reason) => {
316 let _ = writeln!(
317 out,
318 "Workspace restore points could not be read ({}). Treat the correlation below as unavailable rather than empty.\n",
319 inline_text(reason)
320 );
321 }
322 Self::Recorded(snapshots) if snapshots.is_empty() => {
323 out.push_str(
324 "A snapshot repository exists for this workspace but records no restore points yet.\n\n",
325 );
326 }
327 Self::Recorded(snapshots) => {
328 let _ = writeln!(
329 out,
330 "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",
331 snapshots.len()
332 );
333 out.push_str(
334 "> 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",
335 );
336 out.push_str("| N | Restore point | Recorded (UTC) | Label |\n");
337 out.push_str("| --- | --- | --- | --- |\n");
338 for (index, snapshot) in snapshots.iter().enumerate() {
339 let _ = writeln!(
340 out,
341 "| {} | `{}` | {} | {} |",
342 index + 1,
343 short_restore_id(snapshot.id.as_str()),
344 format_snapshot_time(snapshot.timestamp),
345 inline_text(&snapshot.label)
346 );
347 }
348 out.push('\n');
349 }
350 }
351 }
352
353 /// Append the restore points correlated to a single user message.
354 ///
355 /// Correlation is by the prompt snippet the snapshot label actually
356 /// embeds, produced by the same function the snapshot writer uses. No
357 /// message-index-to-turn-sequence mapping is invented: a turn sequence and
358 /// an export message index are different counters, and asserting they line
359 /// up would be a guess presented as provenance.
360 fn render_correlation(&self, out: &mut String, message: &Message) {
361 if !message.role.trim().eq_ignore_ascii_case("user") {
362 return;
363 }
364 let Self::Recorded(snapshots) = self else {
365 return;
366 };
367 let Some(text) = first_text_block(message) else {
368 return;
369 };
370 let Some(snippet) = crate::core::turn::snapshot_label_prompt_snippet(text) else {
371 return;
372 };
373
374 let matches: Vec<(usize, &crate::snapshot::Snapshot)> = snapshots
375 .iter()
376 .enumerate()
377 .filter(|(_, snapshot)| {
378 let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label);
379 matches!(parsed.kind.as_str(), "pre-turn" | "post-turn")
380 && parsed.prompt_snippet.as_deref() == Some(snippet.as_str())
381 })
382 .collect();
383
384 if matches.is_empty() {
385 out.push_str(
386 "- Restore points: none recorded for this message within the listed window.\n\n",
387 );
388 return;
389 }
390
391 let ambiguous = matches.len() > 1;
392 let rendered: Vec<String> = matches
393 .iter()
394 .map(|(index, snapshot)| {
395 let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label);
396 let seq = parsed
397 .seq
398 .map(|seq| format!(" turn {seq}"))
399 .unwrap_or_default();
400 format!(
401 "N{} `{}` ({}{})",
402 index + 1,
403 short_restore_id(snapshot.id.as_str()),
404 parsed.kind,
405 seq
406 )
407 })
408 .collect();
409 let _ = writeln!(out, "- Restore points: {}", rendered.join(", "));
410 if ambiguous {
411 out.push_str(
412 " - More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring.\n",
413 );
414 }
415 out.push('\n');
416 }
417 }
418
419 fn short_restore_id(id: &str) -> String {
420 id.chars().take(RESTORE_POINT_ID_LEN).collect()
421 }
422
423 fn format_snapshot_time(timestamp: i64) -> String {
424 chrono::DateTime::from_timestamp(timestamp, 0)
425 .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
426 .unwrap_or_else(|| "unknown".to_string())
427 }
428
429 fn first_text_block(message: &Message) -> Option<&str> {
430 message.content.iter().find_map(|block| match block {
431 ContentBlock::Text { text, .. } => Some(text.as_str()),
432 _ => None,
433 })
434 }
435
436 fn render_message(out: &mut String, index: usize, message: &Message) {
437 let role = inline_text(&message.role);
438 let _ = writeln!(out, "## {index}. {role}\n");
439 if is_internal_role(&message.role) {
440 out.push_str("[internal context omitted]\n\n");
441 return;
442 }
443 if message.content.is_empty() {
444 out.push_str("[no content]\n\n");
445 return;
446 }
447 for (block_index, block) in message.content.iter().enumerate() {
448 render_content_block(out, block_index + 1, block);
449 }
450 }
451
452 fn render_content_block(out: &mut String, index: usize, block: &ContentBlock) {
453 match block {
454 ContentBlock::Text { text, .. } => {
455 let _ = writeln!(out, "### Content {index}: Text\n");
456 push_sanitized_text(out, text);
457 }
458 ContentBlock::ImageUrl { image_url } => {
459 let _ = writeln!(out, "### Content {index}: Image attachment\n");
460 if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") {
461 let _ = writeln!(
462 out,
463 "- Reference: {}\n",
464 inline_text(&crate::client::redact_url_for_display(&image_url.url))
465 );
466 } else {
467 out.push_str("- Reference omitted (inline or local image payload)\n\n");
468 }
469 }
470 ContentBlock::Thinking { .. } => {
471 let _ = writeln!(out, "### Content {index}: Internal reasoning\n");
472 out.push_str("[internal reasoning and signature omitted]\n\n");
473 }
474 ContentBlock::ToolUse {
475 id,
476 name,
477 input,
478 caller,
479 } => {
480 let _ = writeln!(out, "### Content {index}: Tool call\n");
481 let _ = writeln!(out, "- ID: {}", inline_text(id));
482 let _ = writeln!(out, "- Name: {}", inline_text(name));
483 if let Some(caller) = caller {
484 let _ = writeln!(out, "- Caller type: {}", inline_text(&caller.caller_type));
485 if let Some(tool_id) = caller.tool_id.as_deref() {
486 let _ = writeln!(out, "- Caller tool ID: {}", inline_text(tool_id));
487 }
488 }
489 out.push_str("\nInput:\n\n");
490 push_json(out, input);
491 }
492 ContentBlock::ToolResult {
493 tool_use_id,
494 content,
495 is_error,
496 content_blocks,
497 } => {
498 let _ = writeln!(out, "### Content {index}: Tool result\n");
499 let _ = writeln!(out, "- Tool call ID: {}", inline_text(tool_use_id));
500 let _ = writeln!(out, "- Error: {}\n", is_error.unwrap_or(false));
501 out.push_str("Result:\n\n");
502 push_sanitized_text(out, content);
503 if let Some(blocks) = content_blocks {
504 out.push_str("Structured result blocks:\n\n");
505 push_json(out, &Value::Array(blocks.clone()));
506 }
507 }
508 ContentBlock::ServerToolUse { id, name, input } => {
509 let _ = writeln!(out, "### Content {index}: Server tool call\n");
510 let _ = writeln!(out, "- ID: {}", inline_text(id));
511 let _ = writeln!(out, "- Name: {}\n", inline_text(name));
512 out.push_str("Input:\n\n");
513 push_json(out, input);
514 }
515 ContentBlock::ToolSearchToolResult {
516 tool_use_id,
517 content,
518 } => {
519 let _ = writeln!(out, "### Content {index}: Tool-search result\n");
520 let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(tool_use_id));
521 push_json(out, content);
522 }
523 ContentBlock::CodeExecutionToolResult {
524 tool_use_id,
525 content,
526 } => {
527 let _ = writeln!(out, "### Content {index}: Code-execution result\n");
528 let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(tool_use_id));
529 push_json(out, content);
530 }
531 }
532 }
533
534 fn render_history_fallback(out: &mut String, history: &[HistoryCell]) {
535 if history.is_empty() {
536 out.push_str("## Conversation\n\n[empty conversation]\n");
537 return;
538 }
539 out.push_str(
540 "> Structured API messages were unavailable; the entries below are a sanitized visible-history fallback.\n\n",
541 );
542 for (index, cell) in history.iter().enumerate() {
543 let (role, body) = match cell {
544 HistoryCell::User { content } => ("user", sanitize_text(content)),
545 HistoryCell::Assistant { content, .. } => ("assistant", sanitize_text(content)),
546 HistoryCell::System { .. } => ("system", "[internal context omitted]".to_string()),
547 HistoryCell::Error { message, severity } => {
548 let role = match severity {
549 crate::error_taxonomy::ErrorSeverity::Info => "info",
550 crate::error_taxonomy::ErrorSeverity::Warning => "warning",
551 crate::error_taxonomy::ErrorSeverity::Error => "error",
552 crate::error_taxonomy::ErrorSeverity::Critical => "critical error",
553 };
554 (role, sanitize_text(message))
555 }
556 HistoryCell::Thinking { .. } => (
557 "internal reasoning",
558 "[internal reasoning omitted]".to_string(),
559 ),
560 HistoryCell::Tool(tool) => ("tool", sanitize_text(&render_lines(tool.lines(120)))),
561 HistoryCell::SubAgent(subagent) => (
562 "sub-agent",
563 sanitize_text(&render_lines(subagent.lines(120))),
564 ),
565 HistoryCell::ArchivedContext {
566 level,
567 range,
568 summary,
569 ..
570 } => (
571 "archived context",
572 sanitize_text(&format!("L{level} [{range}]: {summary}")),
573 ),
574 };
575 let _ = writeln!(out, "## {}. {}\n", index + 1, inline_text(role));
576 push_pre_sanitized_text(out, &body);
577 }
578 }
579
580 fn render_lines(lines: Vec<ratatui::text::Line<'static>>) -> String {
581 lines
582 .into_iter()
583 .map(|line| {
584 line.spans
585 .into_iter()
586 .map(|span| span.content.to_string())
587 .collect::<String>()
588 })
589 .collect::<Vec<_>>()
590 .join("\n")
591 }
592
593 fn push_sanitized_text(out: &mut String, text: &str) {
594 push_pre_sanitized_text(out, &sanitize_text(text));
595 }
596
597 fn push_pre_sanitized_text(out: &mut String, text: &str) {
598 if text.trim().is_empty() {
599 out.push_str("[empty text]\n\n");
600 } else {
601 out.push_str(text.trim_end());
602 out.push_str("\n\n");
603 }
604 }
605
606 fn push_json(out: &mut String, value: &Value) {
607 let mut redacted = value.clone();
608 redact_json(&mut redacted, None);
609 let json = serde_json::to_string_pretty(&redacted)
610 .unwrap_or_else(|_| "\"[structured content unavailable]\"".to_string());
611 let fence = markdown_fence(&json);
612 let _ = writeln!(out, "{fence}json\n{json}\n{fence}\n");
613 }
614
615 // Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam
616 // instead of copying it.
617 pub(super) fn redact_json(value: &mut Value, key: Option<&str>) {
618 if key.is_some_and(is_sensitive_key) {
619 *value = Value::String("[redacted]".to_string());
620 return;
621 }
622 match value {
623 Value::String(text) => *text = sanitize_text(text),
624 Value::Array(items) => {
625 for item in items {
626 redact_json(item, None);
627 }
628 }
629 Value::Object(map) => {
630 for (key, value) in map {
631 redact_json(value, Some(key));
632 }
633 }
634 Value::Null | Value::Bool(_) | Value::Number(_) => {}
635 }
636 }
637
638 // Widened to `pub(super)` so `/structcopy` can classify a key again after
639 // removing control/ANSI obfuscation. Classification before and after
640 // normalization keeps the shared sensitive-key vocabulary authoritative.
641 pub(super) fn is_sensitive_key(key: &str) -> bool {
642 let normalized = key
643 .trim()
644 .trim_matches(['\'', '"'])
645 .replace(['-', '.', ' '], "_")
646 .to_ascii_lowercase();
647 [
648 "api_key",
649 "apikey",
650 "secret",
651 "token",
652 "password",
653 "passwd",
654 "authorization",
655 "access_key",
656 "client_secret",
657 "private_key",
658 "cookie",
659 "session_key",
660 ]
661 .iter()
662 .any(|hint| normalized.contains(hint))
663 }
664
665 // Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam
666 // instead of copying it.
667 pub(super) fn sanitize_text(input: &str) -> String {
668 let mut visible = String::with_capacity(input.len());
669 crate::tui::osc8::strip_ansi_into(input, &mut visible);
670 let visible = visible.replace("\r\n", "\n").replace('\r', "\n");
671 let visible: String = visible
672 .chars()
673 .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
674 .collect();
675 let private_keys = private_key_regex().replace_all(&visible, "[redacted private key]");
676 let bearer = bearer_regex().replace_all(&private_keys, "Bearer [redacted]");
677 let jwt = jwt_regex().replace_all(&bearer, "[redacted token]");
678 let urls = url_regex().replace_all(&jwt, |captures: &regex::Captures<'_>| {
679 redact_url_match(captures.get(0).map_or("", |value| value.as_str()))
680 });
681 codewhale_config::persistence::redact_secrets(&urls)
682 }
683
684 fn redact_url_match(raw: &str) -> String {
685 let trimmed = raw.trim_end_matches(['.', ',', ';', '!']);
686 let suffix = &raw[trimmed.len()..];
687 format!(
688 "{}{}",
689 crate::client::redact_url_for_display(trimmed),
690 suffix
691 )
692 }
693
694 fn inline_text(input: &str) -> String {
695 sanitize_text(input)
696 .split_whitespace()
697 .collect::<Vec<_>>()
698 .join(" ")
699 .replace('`', "'")
700 }
701
702 // Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam
703 // instead of copying it.
704 pub(super) fn is_internal_role(role: &str) -> bool {
705 matches!(
706 role.trim().to_ascii_lowercase().as_str(),
707 "system" | "developer" | "internal"
708 )
709 }
710
711 fn markdown_fence(content: &str) -> String {
712 let longest = content
713 .split(|ch| ch != '`')
714 .map(str::len)
715 .max()
716 .unwrap_or(0);
717 "`".repeat(longest.saturating_add(1).max(3))
718 }
719
720 fn private_key_regex() -> &'static Regex {
721 static RE: OnceLock<Regex> = OnceLock::new();
722 RE.get_or_init(|| {
723 Regex::new(
724 r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----",
725 )
726 .expect("private-key redaction regex")
727 })
728 }
729
730 fn bearer_regex() -> &'static Regex {
731 static RE: OnceLock<Regex> = OnceLock::new();
732 RE.get_or_init(|| {
733 Regex::new(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}").expect("bearer redaction regex")
734 })
735 }
736
737 fn jwt_regex() -> &'static Regex {
738 static RE: OnceLock<Regex> = OnceLock::new();
739 RE.get_or_init(|| {
740 Regex::new(r"\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}(?:\.[a-zA-Z0-9_-]{5,})?\b")
741 .expect("JWT redaction regex")
742 })
743 }
744
745 fn url_regex() -> &'static Regex {
746 static RE: OnceLock<Regex> = OnceLock::new();
747 RE.get_or_init(|| {
748 Regex::new(r#"https?://[^\s<>\"'`\]\[\)\(\}\{]+"#).expect("URL redaction regex")
749 })
750 }
751
752 fn sanitize_turn_handoff(app: &App, markdown: &str) -> String {
753 let sanitized = sanitize_text(markdown);
754 let workspace = app.workspace.to_string_lossy();
755 if workspace.is_empty() {
756 sanitized
757 } else {
758 sanitized.replace(workspace.as_ref(), ".")
759 }
760 }
761
762 fn resolve_export_path(workspace: &Path, raw: &str) -> Result<PathBuf, String> {
763 let raw = raw.trim();
764 if raw.is_empty() {
765 return Err("export path is empty".to_string());
766 }
767 let requested = PathBuf::from(raw);
768 if requested
769 .components()
770 .any(|component| component == Component::ParentDir)
771 {
772 return Err(
773 "export paths may not contain `..`; use an explicit normalized absolute path instead"
774 .to_string(),
775 );
776 }
777 // Resolve the trusted workspace root once so platform aliases such as
778 // macOS `/var -> /private/var` do not make every workspace-relative
779 // export look like it traverses a user-controlled symlink. Requested
780 // components beneath that root remain lexical and are checked below.
781 let resolved_workspace =
782 fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
783 let path = if requested.is_absolute() {
784 if let Ok(relative) = requested.strip_prefix(workspace) {
785 resolved_workspace.join(relative)
786 } else if let Ok(relative) = requested.strip_prefix(&resolved_workspace) {
787 resolved_workspace.join(relative)
788 } else {
789 requested
790 }
791 } else {
792 resolved_workspace.join(requested)
793 };
794 if path.file_name().is_none() {
795 return Err(format!("export path must name a file: {}", path.display()));
796 }
797 Ok(path)
798 }
799
800 fn write_export_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
801 let parent = path
802 .parent()
803 .filter(|parent| !parent.as_os_str().is_empty())
804 .ok_or_else(|| format!("path has no parent directory: {}", path.display()))?;
805 let parent_metadata = fs::metadata(parent).map_err(|err| {
806 format!(
807 "parent directory {} is unavailable: {err}",
808 parent.display()
809 )
810 })?;
811 if !parent_metadata.is_dir() {
812 return Err(format!("parent is not a directory: {}", parent.display()));
813 }
814 reject_symlink_components(path)?;
815
816 match fs::symlink_metadata(path) {
817 Ok(_) if !force => {
818 return Err(format!(
819 "destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
820 path.display()
821 ));
822 }
823 Ok(metadata) if !metadata.file_type().is_file() => {
824 return Err(format!(
825 "refusing to replace a non-regular file: {}",
826 path.display()
827 ));
828 }
829 Ok(_) => {}
830 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
831 Err(err) => return Err(format!("could not inspect {}: {err}", path.display())),
832 }
833
834 if force {
835 crate::utils::write_atomic(path, contents).map_err(|err| err.to_string())?;
836 set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))?;
837 return Ok(());
838 }
839
840 let mut options = OpenOptions::new();
841 options.write(true).create_new(true);
842 #[cfg(unix)]
843 {
844 use std::os::unix::fs::OpenOptionsExt;
845 options.mode(0o600);
846 }
847 let mut file = options.open(path).map_err(|err| {
848 if err.kind() == std::io::ErrorKind::AlreadyExists {
849 format!(
850 "destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
851 path.display()
852 )
853 } else {
854 err.to_string()
855 }
856 })?;
857 if let Err(err) = file.write_all(contents).and_then(|()| file.sync_all()) {
858 drop(file);
859 let _ = fs::remove_file(path);
860 return Err(err.to_string());
861 }
862 set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))
863 }
864
865 fn reject_symlink_components(path: &Path) -> Result<(), String> {
866 for component_path in path.ancestors() {
867 match fs::symlink_metadata(component_path) {
868 Ok(metadata) if metadata.file_type().is_symlink() => {
869 return Err(format!(
870 "refusing export through symlink component: {}",
871 component_path.display()
872 ));
873 }
874 Ok(_) => {}
875 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
876 Err(err) => {
877 return Err(format!(
878 "could not inspect path component {}: {err}",
879 component_path.display()
880 ));
881 }
882 }
883 }
884 Ok(())
885 }
886
887 #[cfg(unix)]
888 fn set_owner_only(path: &Path) -> std::io::Result<()> {
889 use std::os::unix::fs::PermissionsExt;
890 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
891 }
892
893 #[cfg(not(unix))]
894 fn set_owner_only(_path: &Path) -> std::io::Result<()> {
895 Ok(())
896 }
897
898 #[cfg(test)]
899 mod tests {
900 use super::*;
901 use crate::config::Config;
902 use crate::models::{ImageUrlContent, ToolCaller};
903 use crate::tui::app::{App, TuiOptions};
904 use crate::tui::clipboard::ClipboardHandler;
905 use tempfile::TempDir;
906
907 fn test_app(tmpdir: &TempDir) -> App {
908 let options = TuiOptions {
909 skills_dir: tmpdir.path().join("skills"),
910 memory_path: tmpdir.path().join("memory.md"),
911 notes_path: tmpdir.path().join("notes.txt"),
912 mcp_config_path: tmpdir.path().join("mcp.json"),
913 ..crate::test_support::test_tui_options(tmpdir.path())
914 };
915 App::new(options, &Config::default())
916 }
917
918 #[test]
919 fn default_clipboard_export_preserves_structure_and_redacts_secrets() {
920 let tmpdir = TempDir::new().expect("tempdir");
921 let mut app = test_app(&tmpdir);
922 app.current_session_id = Some("session-123456789".to_string());
923 app.api_messages = vec![
924 Message {
925 role: "system".to_string(),
926 content: vec![ContentBlock::Text {
927 text: "hidden policy must never export".to_string(),
928 cache_control: None,
929 }],
930 },
931 Message {
932 role: "user".to_string(),
933 content: vec![ContentBlock::Text {
934 text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(),
935 cache_control: None,
936 }],
937 },
938 Message {
939 role: "assistant".to_string(),
940 content: vec![
941 ContentBlock::Thinking {
942 thinking: "private chain of thought".to_string(),
943 signature: Some("signature-secret".to_string()),
944 },
945 ContentBlock::ToolUse {
946 id: "call-1".to_string(),
947 name: "fetch_url".to_string(),
948 input: serde_json::json!({
949 "url": "https://alice:password@example.com/path?token=very-secret&ok=1",
950 "api_key": "literal-api-secret",
951 "nested": {"authorization": "Bearer abcdefghijklmnop"},
952 }),
953 caller: Some(ToolCaller {
954 caller_type: "code_execution_20250825".to_string(),
955 tool_id: Some("server-tool-1".to_string()),
956 }),
957 },
958 ],
959 },
960 Message {
961 role: "user".to_string(),
962 content: vec![ContentBlock::ToolResult {
963 tool_use_id: "call-1".to_string(),
964 content: "Authorization: Bearer another-secret-token\nresult ok".to_string(),
965 is_error: Some(false),
966 content_blocks: Some(vec![serde_json::json!({
967 "image": "https://example.com/a.png?api_key=hidden",
968 "session_token": "session-secret",
969 })]),
970 }],
971 },
972 Message {
973 role: "assistant".to_string(),
974 content: vec![ContentBlock::ImageUrl {
975 image_url: ImageUrlContent {
976 url: "data:image/png;base64,very-secret-image-data".to_string(),
977 },
978 }],
979 },
980 ];
981 {
982 let mut todos = app.todos.try_lock().expect("todos lock");
983 todos.add(
984 "export projection".to_string(),
985 crate::tools::todo::TodoStatus::InProgress,
986 );
987 }
988 app.cycle_effort();
989 let work_before = app.work_state_snapshot().expect("Work snapshot");
990
991 let result = execute_export(&mut app, None);
992
993 assert!(!result.is_error, "{:?}", result.message);
994 assert!(
995 result
996 .message
997 .as_deref()
998 .unwrap_or_default()
999 .contains("local clipboard")
1000 );
1001 let markdown = app
1002 .clipboard
1003 .last_written_text()
1004 .expect("clipboard payload");
1005 let system = markdown.find("## 1. system").expect("system role");
1006 let user = markdown.find("## 2. user").expect("user role");
1007 let assistant = markdown.find("## 3. assistant").expect("assistant role");
1008 let tool_result = markdown.find("## 4. user").expect("tool-result role");
1009 assert!(system < user && user < assistant && assistant < tool_result);
1010 assert!(markdown.contains("[internal context omitted]"));
1011 assert!(markdown.contains("call-1"));
1012 assert!(markdown.contains("fetch_url"));
1013 assert!(markdown.contains("server-tool-1"));
1014 assert!(markdown.contains("[internal reasoning and signature omitted]"));
1015 assert!(markdown.contains("[redacted]"));
1016 assert!(markdown.contains("https://***:***@example.com/path?token=***&ok=1"));
1017 assert!(markdown.contains("Reference omitted (inline or local image payload)"));
1018 let workspace_path = tmpdir.path().to_string_lossy().into_owned();
1019 for forbidden in [
1020 "hidden policy must never export",
1021 "private chain of thought",
1022 "signature-secret",
1023 "literal-api-secret",
1024 "very-secret",
1025 "another-secret-token",
1026 "session-secret",
1027 "very-secret-image-data",
1028 "\u{1b}[31m",
1029 workspace_path.as_str(),
1030 ] {
1031 assert!(
1032 !markdown.contains(forbidden),
1033 "leaked {forbidden:?}: {markdown}"
1034 );
1035 }
1036 assert_eq!(
1037 app.work_state_snapshot()
1038 .expect("Work snapshot after export"),
1039 work_before,
1040 "export must not mutate Work"
1041 );
1042 }
1043
1044 #[test]
1045 fn clipboard_export_reports_ssh_terminal_client_and_failure_honestly() {
1046 let tmpdir = TempDir::new().expect("tempdir");
1047 let mut app = test_app(&tmpdir);
1048 app.clipboard = ClipboardHandler::for_test(true, true);
1049 let ssh = execute_export(&mut app, Some("clipboard"));
1050 assert!(!ssh.is_error, "{:?}", ssh.message);
1051 assert!(
1052 ssh.message
1053 .as_deref()
1054 .unwrap_or_default()
1055 .contains("terminal-client clipboard over SSH")
1056 );
1057
1058 app.clipboard = ClipboardHandler::unavailable_for_test(false);
1059 let failed = execute_export(&mut app, Some("clipboard"));
1060 assert!(failed.is_error);
1061 let message = failed.message.as_deref().unwrap_or_default();
1062 assert!(message.contains("No file was written"), "{message}");
1063 assert!(message.contains("/export file <path>"), "{message}");
1064 assert!(!tmpdir.path().join("chat_export.md").exists());
1065 }
1066
1067 #[test]
1068 fn file_export_is_workspace_relative_private_and_no_overwrite_by_default() {
1069 let tmpdir = TempDir::new().expect("tempdir");
1070 let mut app = test_app(&tmpdir);
1071 app.api_messages.push(Message {
1072 role: "user".to_string(),
1073 content: vec![ContentBlock::Text {
1074 text: "first export".to_string(),
1075 cache_control: None,
1076 }],
1077 });
1078
1079 let first = execute_export(&mut app, Some("file transcript.md"));
1080 assert!(!first.is_error, "{:?}", first.message);
1081 let path = tmpdir.path().join("transcript.md");
1082 let original = fs::read_to_string(&path).expect("first export");
1083 assert!(original.contains("first export"));
1084 #[cfg(unix)]
1085 {
1086 use std::os::unix::fs::PermissionsExt;
1087 assert_eq!(
1088 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1089 0o600
1090 );
1091 }
1092
1093 app.api_messages[0].content = vec![ContentBlock::Text {
1094 text: "replacement export".to_string(),
1095 cache_control: None,
1096 }];
1097 let refused = execute_export(&mut app, Some("transcript.md"));
1098 assert!(refused.is_error);
1099 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1100
1101 let forced = execute_export(&mut app, Some("file --force transcript.md"));
1102 assert!(!forced.is_error, "{:?}", forced.message);
1103 assert!(
1104 fs::read_to_string(&path)
1105 .unwrap()
1106 .contains("replacement export")
1107 );
1108 }
1109
1110 #[test]
1111 fn file_export_rejects_traversal_missing_parent_and_invalid_usage() {
1112 let tmpdir = TempDir::new().expect("tempdir");
1113 let mut app = test_app(&tmpdir);
1114 for arg in [
1115 "file ../outside.md",
1116 "file missing/export.md",
1117 "file",
1118 "file --force",
1119 "clipboard extra.md",
1120 "turn clipboard extra.md",
1121 ] {
1122 let result = execute_export(&mut app, Some(arg));
1123 assert!(result.is_error, "{arg}: {:?}", result.message);
1124 }
1125 assert!(!tmpdir.path().join("outside.md").exists());
1126 }
1127
1128 #[cfg(unix)]
1129 #[test]
1130 fn file_export_rejects_symlink_leaf_and_ancestor() {
1131 use std::os::unix::fs::symlink;
1132
1133 let tmpdir = TempDir::new().expect("tempdir");
1134 let mut app = test_app(&tmpdir);
1135 let real_file = tmpdir.path().join("real.md");
1136 fs::write(&real_file, "keep").expect("fixture file");
1137 let leaf = tmpdir.path().join("leaf.md");
1138 symlink(&real_file, &leaf).expect("leaf symlink");
1139 let leaf_result =
1140 execute_export(&mut app, Some(&format!("file --force {}", leaf.display())));
1141 assert!(leaf_result.is_error, "{:?}", leaf_result.message);
1142 assert_eq!(fs::read_to_string(&real_file).unwrap(), "keep");
1143
1144 let real_dir = tmpdir.path().join("real-dir");
1145 fs::create_dir(&real_dir).expect("real dir");
1146 let linked_dir = tmpdir.path().join("linked-dir");
1147 symlink(&real_dir, &linked_dir).expect("dir symlink");
1148 let ancestor_result = execute_export(
1149 &mut app,
1150 Some(&format!("file {}", linked_dir.join("out.md").display())),
1151 );
1152 assert!(ancestor_result.is_error, "{:?}", ancestor_result.message);
1153 assert!(!real_dir.join("out.md").exists());
1154 }
1155
1156 #[test]
1157 fn turn_export_supports_clipboard_and_safe_legacy_file_destination() {
1158 let tmpdir = TempDir::new().expect("tempdir");
1159 let mut app = test_app(&tmpdir);
1160 app.history.push(HistoryCell::User {
1161 content: "Fix the flaky login test".to_string(),
1162 });
1163 app.history.push(HistoryCell::Assistant {
1164 content: "Fixed the login test.".to_string(),
1165 streaming: false,
1166 });
1167 app.runtime_turn_status = Some("completed".to_string());
1168
1169 let clipboard = execute_export(&mut app, Some("turn"));
1170 assert!(!clipboard.is_error, "{:?}", clipboard.message);
1171 assert!(
1172 app.clipboard
1173 .last_written_text()
1174 .unwrap_or_default()
1175 .contains("# Turn handoff")
1176 );
1177
1178 let path = tmpdir.path().join("handoff.md");
1179 let file = execute_export(&mut app, Some(&format!("turn {}", path.display())));
1180 assert!(!file.is_error, "{:?}", file.message);
1181 assert!(
1182 fs::read_to_string(&path)
1183 .unwrap()
1184 .contains("Fix the flaky login test")
1185 );
1186 let refused = execute_export(&mut app, Some(&format!("turn {}", path.display())));
1187 assert!(refused.is_error);
1188 }
1189
1190 #[test]
1191 fn parser_keeps_paths_with_spaces_and_legacy_forms() {
1192 assert_eq!(
1193 parse_request(Some("file --force reports/chat export.md")).unwrap(),
1194 ExportRequest {
1195 scope: ExportScope::Conversation,
1196 destination: ExportDestination::File {
1197 path: "reports/chat export.md".to_string(),
1198 force: true,
1199 },
1200 }
1201 );
1202 assert_eq!(
1203 parse_request(Some("legacy export.md")).unwrap(),
1204 ExportRequest {
1205 scope: ExportScope::Conversation,
1206 destination: ExportDestination::File {
1207 path: "legacy export.md".to_string(),
1208 force: false,
1209 },
1210 }
1211 );
1212 }
1213
1214 fn snapshot(id: &str, label: &str, timestamp: i64) -> crate::snapshot::Snapshot {
1215 crate::snapshot::Snapshot {
1216 id: crate::snapshot::SnapshotId(id.to_string()),
1217 label: label.to_string(),
1218 timestamp,
1219 session_id: None,
1220 }
1221 }
1222
1223 fn user_message(text: &str) -> Message {
1224 Message {
1225 role: "user".to_string(),
1226 content: vec![ContentBlock::Text {
1227 text: text.to_string(),
1228 cache_control: None,
1229 }],
1230 }
1231 }
1232
1233 #[test]
1234 fn restore_point_summary_lists_each_point_with_its_restore_index() {
1235 let points = RestorePoints::Recorded(vec![
1236 snapshot(
1237 "a".repeat(40).as_str(),
1238 "pre-turn:2: second prompt",
1239 1_700_000_100,
1240 ),
1241 snapshot(
1242 "b".repeat(40).as_str(),
1243 "pre-turn:1: first prompt",
1244 1_700_000_000,
1245 ),
1246 ]);
1247 let mut out = String::new();
1248 points.render_summary(&mut out);
1249
1250 assert!(out.contains("## Restore points"), "{out}");
1251 assert!(
1252 out.contains(
1253 "| 1 | `aaaaaaaaaaaa` | 2023-11-14T22:15:00Z | pre-turn:2: second prompt |"
1254 ),
1255 "newest point must be restore index 1: {out}"
1256 );
1257 assert!(
1258 out.contains(
1259 "| 2 | `bbbbbbbbbbbb` | 2023-11-14T22:13:20Z | pre-turn:1: first prompt |"
1260 ),
1261 "{out}"
1262 );
1263 assert!(
1264 out.contains("`/restore <N>`"),
1265 "the export must name the command that consumes the index: {out}"
1266 );
1267 assert!(
1268 out.contains("position at export time"),
1269 "the index is only valid until the next snapshot; say so: {out}"
1270 );
1271 }
1272
1273 #[test]
1274 fn user_message_correlates_to_its_own_restore_point() {
1275 let points = RestorePoints::Recorded(vec![
1276 snapshot(
1277 "c".repeat(40).as_str(),
1278 "pre-turn:4: rename the widget",
1279 1_700_000_200,
1280 ),
1281 snapshot(
1282 "d".repeat(40).as_str(),
1283 "pre-turn:3: unrelated prompt",
1284 1_700_000_100,
1285 ),
1286 ]);
1287 let mut out = String::new();
1288 points.render_correlation(&mut out, &user_message("rename the widget\ndetail line"));
1289
1290 assert!(
1291 out.contains("- Restore points: N1 `cccccccccccc` (pre-turn turn 4)"),
1292 "{out}"
1293 );
1294 assert!(
1295 !out.contains("dddddddddddd"),
1296 "an unrelated prompt must not be correlated: {out}"
1297 );
1298 }
1299
1300 #[test]
1301 fn repeated_prompts_are_reported_as_ambiguous_rather_than_guessed() {
1302 let points = RestorePoints::Recorded(vec![
1303 snapshot(
1304 "e".repeat(40).as_str(),
1305 "pre-turn:9: run the tests",
1306 1_700_000_300,
1307 ),
1308 snapshot(
1309 "f".repeat(40).as_str(),
1310 "pre-turn:5: run the tests",
1311 1_700_000_100,
1312 ),
1313 ]);
1314 let mut out = String::new();
1315 points.render_correlation(&mut out, &user_message("run the tests"));
1316
1317 assert!(out.contains("N1 `eeeeeeeeeeee` (pre-turn turn 9)"), "{out}");
1318 assert!(out.contains("N2 `ffffffffffff` (pre-turn turn 5)"), "{out}");
1319 assert!(
1320 out.contains("ambiguous"),
1321 "two identical prompts must not be silently resolved to one: {out}"
1322 );
1323 }
1324
1325 #[test]
1326 fn a_message_with_no_recorded_restore_point_says_none_rather_than_nothing() {
1327 let points = RestorePoints::Recorded(vec![snapshot(
1328 "1".repeat(40).as_str(),
1329 "pre-turn:1: something else",
1330 1_700_000_000,
1331 )]);
1332 let mut out = String::new();
1333 points.render_correlation(&mut out, &user_message("never snapshotted"));
1334 assert!(out.contains("none recorded for this message"), "{out}");
1335 }
1336
1337 #[test]
1338 fn tool_snapshots_are_not_correlated_to_user_messages() {
1339 let points = RestorePoints::Recorded(vec![snapshot(
1340 "2".repeat(40).as_str(),
1341 "tool:call_abc: rename the widget",
1342 1_700_000_000,
1343 )]);
1344 let mut out = String::new();
1345 points.render_correlation(&mut out, &user_message("rename the widget"));
1346 assert!(
1347 out.contains("none recorded for this message"),
1348 "a per-tool snapshot is not the turn's restore point: {out}"
1349 );
1350 }
1351
1352 #[test]
1353 fn assistant_messages_get_no_correlation_line() {
1354 let points = RestorePoints::Recorded(vec![snapshot(
1355 "3".repeat(40).as_str(),
1356 "pre-turn:1: hello",
1357 1_700_000_000,
1358 )]);
1359 let mut out = String::new();
1360 points.render_correlation(
1361 &mut out,
1362 &Message {
1363 role: "assistant".to_string(),
1364 content: vec![ContentBlock::Text {
1365 text: "hello".to_string(),
1366 cache_control: None,
1367 }],
1368 },
1369 );
1370 assert!(out.is_empty(), "{out}");
1371 }
1372
1373 #[test]
1374 fn absent_snapshot_repo_is_reported_as_unavailable_not_as_an_empty_list() {
1375 let mut out = String::new();
1376 RestorePoints::None.render_summary(&mut out);
1377 assert!(
1378 out.contains("No workspace restore points are recorded"),
1379 "{out}"
1380 );
1381 assert!(
1382 out.contains("nothing in this export can be correlated"),
1383 "the export must not imply restorability it does not have: {out}"
1384 );
1385 }
1386
1387 #[test]
1388 fn unreadable_snapshot_repo_reports_the_reason_instead_of_omitting_it() {
1389 let mut out = String::new();
1390 RestorePoints::Unreadable("permission denied".to_string()).render_summary(&mut out);
1391 assert!(out.contains("could not be read"), "{out}");
1392 assert!(out.contains("permission denied"), "{out}");
1393 assert!(
1394 out.contains("unavailable rather than empty"),
1395 "unknown must stay unknown: {out}"
1396 );
1397 }
1398
1399 #[test]
1400 fn an_existing_repo_with_no_commits_is_distinguished_from_no_repo() {
1401 let mut out = String::new();
1402 RestorePoints::Recorded(Vec::new()).render_summary(&mut out);
1403 assert!(out.contains("records no restore points yet"), "{out}");
1404 }
1405
1406 #[test]
1407 fn export_does_not_create_a_snapshot_repo_for_a_fresh_workspace() {
1408 let tmpdir = TempDir::new().expect("tempdir");
1409 let workspace = tmpdir.path().join("workspace");
1410 std::fs::create_dir_all(&workspace).expect("workspace");
1411 let before = crate::snapshot::snapshot_git_dir(&workspace);
1412 assert!(!before.exists(), "precondition: no side repo yet");
1413
1414 assert!(matches!(
1415 RestorePoints::read(&workspace),
1416 RestorePoints::None
1417 ));
1418
1419 assert!(
1420 !crate::snapshot::snapshot_git_dir(&workspace).exists(),
1421 "reading restore points must never create the side repo"
1422 );
1423 }
1424 }
1425
1425 lines RUST