返回 CodeWhale
session_export_regression_tests.rs
根目录 / crates / tui / src / commands / session_export_regression_tests.rs
1 //! FEAT-025 Phase 3: real-host regression coverage for the session-export
2 //! adapter.
3 //!
4 //! These tests deliberately stay outside `groups/session`, which FEAT-043
5 //! moves into `codewhale-commands`. They exercise the TUI-owned
6 //! `SessionExportAdapter` through the capability envelope and assert the
7 //! baseline host contracts: metadata derivation, data-minimized projections,
8 //! the shared turn-handoff renderer, read-only restore-point projection,
9 //! clipboard/recovery ordering inputs, and protected file resolution/writing.
10 //!
11 //! No test depends on a manual terminal, GUI, device, or live clipboard.
12
13 use std::path::{Path, PathBuf};
14
15 use tempfile::TempDir;
16
17 use codewhale_command_contract::facets::{
18 ConversationExportProjection, ExportBlock, HistoryEntry, RestorePointProjection,
19 RestoreSnapshot, TranscriptProjection, TurnHandoffProjection,
20 };
21 use codewhale_command_contract::handler::CommandCapabilities;
22
23 use crate::config::Config;
24 use crate::error_taxonomy::ErrorSeverity;
25 use crate::snapshot::SnapshotRepo;
26 use crate::test_support::{EnvVarGuard, TestEnvLock};
27 use crate::tui::app::{App, TuiOptions};
28 use crate::tui::clipboard::ClipboardHandler;
29 use crate::tui::history::HistoryCell;
30 use codewhale_models::{ContentBlock, ImageUrlContent, Message, Role, ToolCaller};
31
32 use crate::commands::session_export_test_support::{
33 assert_only_export_facet_exposed, normalize_export_time, normalize_recorded_export,
34 normalize_turn_generated_at,
35 };
36 use crate::commands::{CommandResult, execute};
37
38 struct ExportHarness {
39 app: App,
40 // Guards are declared before the lock and the temporary directory so the
41 // environment is restored before the lock is released and before the
42 // temporary files are removed (matches ControlHarness).
43 _home: EnvVarGuard,
44 _codewhale_home: EnvVarGuard,
45 _env_lock: TestEnvLock,
46 temp: TempDir,
47 }
48
49 impl ExportHarness {
50 fn new() -> Self {
51 let env_lock = crate::test_support::lock_test_env();
52 let temp = TempDir::new().expect("tempdir");
53 let home = temp.path().join("home");
54 std::fs::create_dir_all(&home).expect("home dir");
55 let _home = EnvVarGuard::set("HOME", &home);
56 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
57 let options = TuiOptions {
58 skills_dir: temp.path().join("skills"),
59 memory_path: temp.path().join("memory.md"),
60 notes_path: temp.path().join("notes.txt"),
61 mcp_config_path: temp.path().join("mcp.json"),
62 ..crate::test_support::test_tui_options(temp.path())
63 };
64 let app = App::new(options, &Config::default());
65 Self {
66 app,
67 _home,
68 _codewhale_home,
69 _env_lock: env_lock,
70 temp,
71 }
72 }
73 }
74
75 fn conversation_projection(app: &mut App) -> ConversationExportProjection {
76 let mut bundle = app.command_contexts();
77 let mut parts = bundle.parts();
78 parts
79 .export
80 .as_mut()
81 .expect("export facet")
82 .conversation_projection()
83 }
84
85 fn turn_handoff_projection(app: &mut App) -> TurnHandoffProjection {
86 let mut bundle = app.command_contexts();
87 let mut parts = bundle.parts();
88 parts
89 .export
90 .as_mut()
91 .expect("export facet")
92 .turn_handoff_projection()
93 }
94
95 fn text_message(role: Role, text: &str) -> Message {
96 Message {
97 role,
98 content: vec![ContentBlock::Text {
99 text: text.to_string(),
100 cache_control: None,
101 }],
102 }
103 }
104
105 #[test]
106 fn adapter_projects_authoritative_metadata_and_omits_hidden_payloads() {
107 let mut harness = ExportHarness::new();
108 harness.app.current_session_id = Some("session-123456789".to_string());
109 harness.app.api_messages = std::sync::Arc::new(vec![
110 text_message(Role::System, "hidden policy must never export"),
111 text_message(Role::User, "please inspect\nthe output"),
112 Message {
113 role: Role::Assistant,
114 content: vec![
115 ContentBlock::Thinking {
116 thinking: "private chain of thought".to_string(),
117 signature: Some("signature-secret".to_string()),
118 state: None,
119 },
120 ContentBlock::ToolUse {
121 execution_id: None,
122 id: "call-1".to_string(),
123 name: "fetch_url".to_string(),
124 input: serde_json::json!({"url": "https://example.com/a"}),
125 caller: Some(ToolCaller {
126 caller_type: "code_execution_20250825".to_string(),
127 tool_id: Some("server-tool-1".to_string()),
128 }),
129 thought_signature: None,
130 },
131 ContentBlock::ImageUrl {
132 image_url: ImageUrlContent {
133 url: "data:image/png;base64,very-secret-image-data".to_string(),
134 },
135 },
136 ContentBlock::ImageUrl {
137 image_url: ImageUrlContent {
138 url: "https://example.com/remote.png".to_string(),
139 },
140 },
141 ],
142 },
143 ]);
144
145 let projection = conversation_projection(&mut harness.app);
146
147 assert_eq!(projection.metadata.session_label, "session-");
148 assert_eq!(
149 projection.metadata.provider,
150 harness.app.provider_identity_for_persistence()
151 );
152 assert_eq!(projection.metadata.model, harness.app.model_display_label());
153 assert_eq!(projection.metadata.mode, harness.app.mode.display_name());
154 assert_eq!(
155 projection.metadata.workspace_name,
156 harness
157 .temp
158 .path()
159 .file_name()
160 .and_then(|name| name.to_str())
161 .unwrap()
162 );
163 assert_eq!(projection.metadata.message_count, 3);
164 assert!(projection.metadata.exported_at_unix > 0);
165
166 let TranscriptProjection::Authoritative(messages) = &projection.transcript else {
167 panic!("authoritative transcript expected");
168 };
169 assert_eq!(messages.len(), 3);
170 assert_eq!(messages[1].role, "user");
171 assert_eq!(
172 messages[1].prompt_snippet.as_deref(),
173 Some("please inspect")
174 );
175 assert!(matches!(
176 messages[2].blocks[0],
177 ExportBlock::InternalReasoning
178 ));
179 assert!(matches!(messages[2].blocks[2], ExportBlock::ImageOmitted));
180 assert!(matches!(
181 &messages[2].blocks[3],
182 ExportBlock::ImageReference { url } if url == "https://example.com/remote.png"
183 ));
184 let ExportBlock::ToolCall { caller, .. } = &messages[2].blocks[1] else {
185 panic!("tool call expected");
186 };
187 let caller = caller.as_ref().expect("caller projection");
188 assert_eq!(caller.caller_type, "code_execution_20250825");
189 assert_eq!(caller.tool_id.as_deref(), Some("server-tool-1"));
190
191 let debug = format!("{projection:?}");
192 for forbidden in [
193 "private chain of thought",
194 "signature-secret",
195 "very-secret-image-data",
196 ] {
197 assert!(
198 !debug.contains(forbidden),
199 "hidden payload {forbidden:?} crossed the projection boundary"
200 );
201 }
202 }
203
204 #[test]
205 fn adapter_projects_visible_history_fallback_with_baseline_markers() {
206 let mut harness = ExportHarness::new();
207 harness.app.api_messages_mut().clear();
208 harness.app.history = vec![
209 HistoryCell::User {
210 content: "user text".to_string(),
211 },
212 HistoryCell::Assistant {
213 content: "assistant text".to_string(),
214 streaming: false,
215 },
216 HistoryCell::System {
217 content: "hidden system".to_string(),
218 },
219 HistoryCell::Thinking {
220 content: "hidden reasoning".to_string(),
221 streaming: false,
222 duration_secs: None,
223 },
224 HistoryCell::Error {
225 message: "boom".to_string(),
226 severity: ErrorSeverity::Warning,
227 },
228 ];
229
230 let projection = conversation_projection(&mut harness.app);
231 let TranscriptProjection::HistoryFallback(entries) = &projection.transcript else {
232 panic!("history fallback expected");
233 };
234 assert_eq!(projection.metadata.message_count, 5);
235 assert_eq!(
236 entries[0],
237 HistoryEntry::Sanitized {
238 role: "user".to_string(),
239 body: "user text".to_string(),
240 }
241 );
242 assert_eq!(
243 entries[1],
244 HistoryEntry::Sanitized {
245 role: "assistant".to_string(),
246 body: "assistant text".to_string(),
247 }
248 );
249 assert_eq!(
250 entries[2],
251 HistoryEntry::Literal {
252 role: "system".to_string(),
253 body: "[internal context omitted]".to_string(),
254 }
255 );
256 assert_eq!(
257 entries[3],
258 HistoryEntry::Literal {
259 role: "internal reasoning".to_string(),
260 body: "[internal reasoning omitted]".to_string(),
261 }
262 );
263 assert_eq!(
264 entries[4],
265 HistoryEntry::Sanitized {
266 role: "warning".to_string(),
267 body: "boom".to_string(),
268 }
269 );
270 }
271
272 #[test]
273 fn adapter_reuses_turn_handoff_renderer_and_workspace_value() {
274 let mut harness = ExportHarness::new();
275 harness.app.history.push(HistoryCell::User {
276 content: "Fix the flaky login test".to_string(),
277 });
278 harness.app.history.push(HistoryCell::Assistant {
279 content: "Fixed the login test.".to_string(),
280 streaming: false,
281 });
282 harness.app.runtime_turn_status = Some("completed".to_string());
283
284 let direct = crate::tui::ui::turn_handoff_markdown(&harness.app);
285 let projection = turn_handoff_projection(&mut harness.app);
286
287 // The renderer stamps `generated <wall-clock second>` on every call, so two
288 // calls straddling a second boundary legitimately differ in that one field
289 // (seen on Windows CI: `10:35:58` vs `10:35:59`). Compare everything else
290 // byte-for-byte, and check both calls still carry the stamp.
291 assert!(direct.contains(" \u{b7} generated "));
292 assert!(projection.markdown.contains(" \u{b7} generated "));
293 assert_eq!(
294 normalize_turn_generated_at(&projection.markdown),
295 normalize_turn_generated_at(&direct),
296 "renderer output must not drift"
297 );
298 assert!(projection.markdown.contains("# Turn handoff"));
299 assert_eq!(
300 projection.workspace_path,
301 harness.app.workspace.to_string_lossy().into_owned()
302 );
303 }
304
305 #[test]
306 fn adapter_projects_absent_restore_points_without_creating_a_repo() {
307 let mut harness = ExportHarness::new();
308 let workspace = harness.temp.path().join("workspace");
309 std::fs::create_dir_all(&workspace).expect("workspace");
310 harness.app.workspace = workspace.clone();
311 let before = crate::snapshot::snapshot_git_dir(&workspace).expect("snapshot path");
312 assert!(!before.exists(), "precondition: no side repo yet");
313
314 let projection = conversation_projection(&mut harness.app);
315
316 assert!(matches!(
317 projection.restore_points,
318 RestorePointProjection::None
319 ));
320 assert!(
321 !crate::snapshot::snapshot_git_dir(&workspace)
322 .expect("snapshot path")
323 .exists(),
324 "projection must never create the snapshot repo"
325 );
326 }
327
328 #[test]
329 fn adapter_projects_recorded_restore_points_as_semantic_fields() {
330 let mut harness = ExportHarness::new();
331 let workspace = harness.temp.path().join("workspace");
332 std::fs::create_dir_all(&workspace).expect("workspace");
333 harness.app.workspace = workspace.clone();
334 let repo = SnapshotRepo::open_or_init(&workspace).expect("open side repo");
335 repo.snapshot("pre-turn:2: second prompt")
336 .expect("record snapshot");
337
338 let projection = conversation_projection(&mut harness.app);
339
340 let RestorePointProjection::Recorded { snapshots } = &projection.restore_points else {
341 panic!("recorded restore points expected");
342 };
343 assert_eq!(snapshots.len(), 1);
344 let snapshot: &RestoreSnapshot = &snapshots[0];
345 assert_eq!(snapshot.id.len(), 40, "full id crosses; portable truncates");
346 assert_eq!(snapshot.kind, "pre-turn");
347 assert_eq!(snapshot.sequence, Some(2));
348 assert_eq!(snapshot.prompt_snippet.as_deref(), Some("second prompt"));
349 assert_eq!(snapshot.label, "pre-turn:2: second prompt");
350 assert!(snapshot.timestamp_unix > 0);
351 }
352
353 #[test]
354 fn adapter_projects_user_identity_from_the_role_enum_not_the_string() {
355 // F6: the baseline compared `message.role != Role::User`. Projecting only
356 // the rendered role string would lose that distinction, because
357 // `Role::Unrecognized("user")` renders as "user" but is not `Role::User`.
358 let mut harness = ExportHarness::new();
359 harness.app.api_messages = std::sync::Arc::new(vec![
360 Message {
361 role: Role::Unrecognized("user".to_string()),
362 content: vec![ContentBlock::Text {
363 text: "looks like a user turn".to_string(),
364 cache_control: None,
365 }],
366 },
367 text_message(Role::User, "actually a user turn"),
368 ]);
369
370 let projection = conversation_projection(&mut harness.app);
371 let TranscriptProjection::Authoritative(messages) = &projection.transcript else {
372 panic!("authoritative transcript expected");
373 };
374 assert_eq!(
375 messages[0].role, "user",
376 "the rendered role string is unchanged"
377 );
378 assert!(
379 !messages[0].is_user_role,
380 "Role::Unrecognized(\"user\") must not be treated as a user turn"
381 );
382 assert!(messages[1].is_user_role, "Role::User is a user turn");
383 }
384
385 fn clipboard_facet(app: &mut App) -> bool {
386 let mut bundle = app.command_contexts();
387 let mut parts = bundle.parts();
388 parts
389 .export
390 .as_mut()
391 .expect("export facet")
392 .clipboard_requires_terminal_paste()
393 }
394
395 #[test]
396 fn adapter_exposes_clipboard_mode_recovery_and_delivery_separately() {
397 let mut harness = ExportHarness::new();
398
399 harness.app.clipboard = ClipboardHandler::for_test(true, true);
400 assert!(clipboard_facet(&mut harness.app));
401 harness.app.clipboard = ClipboardHandler::for_test(false, false);
402 assert!(!clipboard_facet(&mut harness.app));
403
404 // Recovery write is one operation and returns the shared path.
405 let recovery = {
406 let mut bundle = harness.app.command_contexts();
407 let mut parts = bundle.parts();
408 parts
409 .export
410 .as_mut()
411 .expect("export facet")
412 .write_recovery_copy("recover me")
413 };
414 let recovery = recovery.expect("recovery path");
415 assert!(recovery.ends_with("exports/last-copy.md"));
416 assert_eq!(
417 std::fs::read_to_string(&recovery).expect("recovery content"),
418 "recover me"
419 );
420
421 // Clipboard delivery is a separate operation and records the payload.
422 harness.app.clipboard = ClipboardHandler::for_test(false, false);
423 {
424 let mut bundle = harness.app.command_contexts();
425 let mut parts = bundle.parts();
426 parts
427 .export
428 .as_mut()
429 .expect("export facet")
430 .write_clipboard("deliver me")
431 .expect("clipboard write");
432 }
433 assert_eq!(
434 harness.app.clipboard.last_written_text(),
435 Some("deliver me")
436 );
437
438 // A failing clipboard still returns the raw host error text.
439 harness.app.clipboard = ClipboardHandler::unavailable_for_test(false);
440 let failure = {
441 let mut bundle = harness.app.command_contexts();
442 let mut parts = bundle.parts();
443 parts
444 .export
445 .as_mut()
446 .expect("export facet")
447 .write_clipboard("nope")
448 };
449 assert!(failure.is_err());
450 }
451
452 fn resolve(app: &mut App, raw: &str) -> Result<PathBuf, String> {
453 let mut bundle = app.command_contexts();
454 let mut parts = bundle.parts();
455 parts
456 .export
457 .as_mut()
458 .expect("export facet")
459 .resolve_export_path(raw)
460 }
461
462 /// Create a workspace directory whose path contains no symlink component.
463 ///
464 /// macOS places `TempDir` under `/var/folders/...`, and `/var` is a symlink to
465 /// `/private/var`. The protected export writer deliberately rejects any path
466 /// with a symlink component, so an adapter-level test that calls
467 /// `write_export_file` directly - bypassing `resolve_export_path`, which
468 /// canonicalizes the workspace for the real command - must hand it an already
469 /// resolved path. Linux temp dirs contain no symlink, so only macOS CI sees the
470 /// difference.
471 fn canonical_workspace(harness: &ExportHarness) -> PathBuf {
472 let root = std::fs::canonicalize(harness.temp.path()).expect("canonical temp root");
473 let workspace = root.join("workspace");
474 std::fs::create_dir_all(&workspace).expect("workspace");
475 workspace
476 }
477
478 fn write_file(
479 app: &mut App,
480 path: &std::path::Path,
481 contents: &[u8],
482 force: bool,
483 ) -> Result<(), String> {
484 let mut bundle = app.command_contexts();
485 let mut parts = bundle.parts();
486 parts
487 .export
488 .as_mut()
489 .expect("export facet")
490 .write_export_file(path, contents, force)
491 }
492
493 #[test]
494 fn adapter_resolves_export_paths_with_baseline_errors() {
495 let mut harness = ExportHarness::new();
496 let workspace = harness.temp.path().join("workspace");
497 std::fs::create_dir_all(&workspace).expect("workspace");
498 harness.app.workspace = workspace.clone();
499
500 assert_eq!(
501 resolve(&mut harness.app, "transcript.md").expect("workspace relative"),
502 std::fs::canonicalize(&workspace)
503 .unwrap()
504 .join("transcript.md")
505 );
506 assert_eq!(
507 resolve(&mut harness.app, "").unwrap_err(),
508 "export path is empty"
509 );
510 assert!(
511 resolve(&mut harness.app, "../outside.md")
512 .unwrap_err()
513 .contains("may not contain `..`")
514 );
515 assert!(
516 resolve(&mut harness.app, "/")
517 .unwrap_err()
518 .starts_with("export path must name a file:")
519 );
520 }
521
522 #[test]
523 fn adapter_preserves_protected_file_write_and_overwrite_refusal() {
524 let mut harness = ExportHarness::new();
525 let workspace = canonical_workspace(&harness);
526 harness.app.workspace = workspace.clone();
527 let target = workspace.join("transcript.md");
528
529 write_file(&mut harness.app, &target, b"first", false).expect("first write");
530 assert_eq!(std::fs::read_to_string(&target).unwrap(), "first");
531 #[cfg(unix)]
532 {
533 use std::os::unix::fs::PermissionsExt;
534 assert_eq!(
535 std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
536 0o600
537 );
538 }
539
540 let refused = write_file(&mut harness.app, &target, b"second", false).unwrap_err();
541 assert!(refused.contains("destination already exists"));
542 assert_eq!(std::fs::read_to_string(&target).unwrap(), "first");
543
544 write_file(&mut harness.app, &target, b"third", true).expect("forced write");
545 assert_eq!(std::fs::read_to_string(&target).unwrap(), "third");
546
547 let missing_parent =
548 write_file(&mut harness.app, &workspace.join("nope/x.md"), b"x", false).unwrap_err();
549 assert!(missing_parent.contains("parent directory"));
550 }
551
552 #[cfg(unix)]
553 #[test]
554 fn adapter_rejects_symlink_leaf_and_ancestor_exports() {
555 use std::os::unix::fs::symlink;
556
557 let mut harness = ExportHarness::new();
558 let workspace = canonical_workspace(&harness);
559 harness.app.workspace = workspace.clone();
560
561 let real_file = workspace.join("real.md");
562 std::fs::write(&real_file, "keep").expect("fixture file");
563 let leaf = workspace.join("leaf.md");
564 symlink(&real_file, &leaf).expect("leaf symlink");
565 let leaf_result = write_file(&mut harness.app, &leaf, b"replace", true).unwrap_err();
566 assert!(leaf_result.contains("symlink component"));
567 assert_eq!(std::fs::read_to_string(&real_file).unwrap(), "keep");
568
569 let real_dir = workspace.join("real-dir");
570 std::fs::create_dir(&real_dir).expect("real dir");
571 let linked_dir = workspace.join("linked-dir");
572 symlink(&real_dir, &linked_dir).expect("dir symlink");
573 let ancestor_result =
574 write_file(&mut harness.app, &linked_dir.join("out.md"), b"x", false).unwrap_err();
575 assert!(ancestor_result.contains("symlink component"));
576 assert!(!real_dir.join("out.md").exists());
577 }
578
579 /// The protected writer refuses any path whose ancestors include a symlink,
580 /// which on macOS is true of everything under `/var` (and therefore every
581 /// `TempDir`) because `/var` is a symlink to `/private/var`. An adapter-level
582 /// test that calls `write_export_file` directly must therefore hand it a
583 /// resolved path - `resolve_export_path` canonicalizes the workspace for the
584 /// real command, and `canonical_workspace` does the same for these tests.
585 ///
586 /// This test builds the symlink itself, so a Linux run catches the class of bug
587 /// that macOS CI caught in `adapter_preserves_protected_file_write_and_overwrite_refusal`
588 /// and `adapter_rejects_directory_destination_and_parent_not_a_directory`.
589 #[cfg(unix)]
590 #[test]
591 fn protected_writer_requires_a_path_free_of_symlink_ancestors() {
592 use std::os::unix::fs::symlink;
593
594 let harness = ExportHarness::new();
595 let real = harness.temp.path().join("real-root");
596 std::fs::create_dir_all(&real).expect("real root");
597 let link = harness.temp.path().join("link-root");
598 symlink(&real, &link).expect("root symlink");
599
600 // Reached through the symlinked root: refused, and nothing is written.
601 let refused =
602 crate::commands::session_export_host::write_export_file(&link.join("out.md"), b"x", false)
603 .expect_err("a symlinked ancestor must be refused");
604 assert!(refused.contains("symlink component"), "{refused}");
605 assert!(!real.join("out.md").exists());
606
607 // The same file through the resolved root is accepted, which is exactly what
608 // `canonical_workspace` supplies.
609 let resolved = std::fs::canonicalize(&link).expect("canonical root");
610 crate::commands::session_export_host::write_export_file(&resolved.join("out.md"), b"x", false)
611 .expect("a resolved path must be writable");
612 assert_eq!(
613 std::fs::read_to_string(resolved.join("out.md")).unwrap(),
614 "x"
615 );
616 }
617
618 #[test]
619 fn envelope_exposes_export_only_for_the_declared_capability() {
620 let mut harness = ExportHarness::new();
621
622 {
623 let mut bundle = harness.app.command_contexts();
624 let export_only = bundle
625 .contexts(CommandCapabilities::SESSION_EXPORT)
626 .into_parts();
627 // Exhaustive across all sixteen `ContextParts` slots (see the helper),
628 // so a newly added facet cannot silently join the export envelope.
629 assert_only_export_facet_exposed(export_only);
630 }
631
632 {
633 let mut bundle = harness.app.command_contexts();
634 let unrelated_only = bundle.contexts(CommandCapabilities::SESSION).into_parts();
635 assert!(
636 unrelated_only.export.is_none(),
637 "export must not be exposed without its declared capability"
638 );
639 }
640 }
641
642 // ---------------------------------------------------------------------------
643 // FEAT-025 Phase 4: full-command parity regressions relocated from the legacy
644 // `groups/session/export.rs` tests. They dispatch through the public command
645 // seam so the portable handler and the TUI export adapter are exercised
646 // together against real clipboard, filesystem, and snapshot fixtures.
647 // ---------------------------------------------------------------------------
648
649 fn dispatch(app: &mut App, arg: Option<&str>) -> CommandResult {
650 match arg {
651 Some(arg) => execute(&format!("/export {arg}"), app),
652 None => execute("/export", app),
653 }
654 }
655
656 // ---------------------------------------------------------------------------
657 // FEAT-025 audit hardening: full-document goldens captured from the
658 // pre-refactor implementation at `3f3aa9ed7` (see
659 // `fixtures/export_conversation_baseline.md` and `export_turn_baseline.md`).
660 //
661 // The goldens were produced by dispatching the same fixture through the
662 // *baseline* public `/export` seam in a scratch worktree (repository state
663 // `3f3aa9ed7`), not authored from the migrated implementation, so a
664 // divergence in the adapter projection or the portable renderer fails here
665 // with an exact byte offset. Only the two host-derived stamps (the export
666 // `- Exported:` line and the turn-handoff header) are normalised; every other
667 // byte, including redaction markers and omission text, is compared verbatim.
668 //
669 // To re-capture, reproduce the fixture below against a verified baseline and
670 // replace the fixture files - never regenerate them from the new code, which
671 // would turn this into a tautology.
672 // ---------------------------------------------------------------------------
673
674 /// Workspace pinned by the captured goldens (a path with no snapshot repo, so
675 /// the baseline restore-point state is `None`).
676 const BASELINE_WORKSPACE: &str = "/workspace/example";
677
678 /// Session id pinned by the captured goldens.
679 const BASELINE_SESSION: &str = "session-123456789";
680
681 /// The exact transcript fixture the baseline goldens were captured from.
682 fn baseline_golden_messages() -> Vec<Message> {
683 vec![
684 Message {
685 role: Role::System,
686 content: vec![ContentBlock::Text {
687 text: "hidden policy must never export".to_string(),
688 cache_control: None,
689 }],
690 },
691 Message {
692 role: Role::User,
693 content: vec![ContentBlock::Text {
694 text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(),
695 cache_control: None,
696 }],
697 },
698 Message {
699 role: Role::Assistant,
700 content: vec![
701 ContentBlock::Thinking {
702 thinking: "private chain of thought".to_string(),
703 signature: Some("signature-secret".to_string()),
704 state: None,
705 },
706 ContentBlock::ToolUse {
707 execution_id: None,
708 id: "call-1".to_string(),
709 name: "fetch_url".to_string(),
710 input: serde_json::json!({
711 "url": "https://alice:password@example.com/path?token=very-secret&ok=1",
712 "api_key": "literal-api-secret",
713 "nested": {"authorization": "Bearer abcdefghijklmnop"},
714 }),
715 caller: Some(ToolCaller {
716 caller_type: "code_execution_20250825".to_string(),
717 tool_id: Some("server-tool-1".to_string()),
718 }),
719 thought_signature: None,
720 },
721 ],
722 },
723 Message {
724 role: Role::User,
725 content: vec![ContentBlock::ToolResult {
726 execution_id: None,
727 tool_use_id: "call-1".to_string(),
728 content: "Authorization: Bearer another-secret-token\nresult ok".to_string(),
729 is_error: Some(false),
730 content_blocks: Some(vec![
731 serde_json::json!({
732 "type": "image",
733 "mime_type": "image/png",
734 "data": "base64verysecretimagedata",
735 }),
736 serde_json::json!({"session_token": "session-secret", "note": "keep me"}),
737 ]),
738 }],
739 },
740 Message {
741 role: Role::Assistant,
742 content: vec![
743 ContentBlock::ImageUrl {
744 image_url: ImageUrlContent {
745 url: "https://example.com/visible.png?token=very-secret".to_string(),
746 },
747 },
748 ContentBlock::ImageUrl {
749 image_url: ImageUrlContent {
750 url: "data:image/png;base64,very-secret-image-data".to_string(),
751 },
752 },
753 ],
754 },
755 Message {
756 role: Role::Assistant,
757 content: vec![ContentBlock::ServerToolUse {
758 id: "srv-1".to_string(),
759 name: "web_search".to_string(),
760 input: serde_json::json!({"query": "secret token"}),
761 }],
762 },
763 Message {
764 role: Role::User,
765 content: vec![ContentBlock::ToolSearchToolResult {
766 tool_use_id: "srv-1".to_string(),
767 content: serde_json::json!({"results": []}),
768 }],
769 },
770 Message {
771 role: Role::Assistant,
772 content: vec![ContentBlock::CodeExecutionToolResult {
773 tool_use_id: "srv-2".to_string(),
774 content: serde_json::json!({"stdout": "ok"}),
775 }],
776 },
777 ]
778 }
779
780 #[test]
781 fn command_clipboard_export_matches_baseline_conversation_golden() {
782 let mut harness = ExportHarness::new();
783 harness.app.workspace = PathBuf::from(BASELINE_WORKSPACE);
784 harness.app.current_session_id = Some(BASELINE_SESSION.to_string());
785 harness.app.clipboard = ClipboardHandler::for_test(false, false);
786 harness.app.api_messages = std::sync::Arc::new(baseline_golden_messages());
787
788 let result = dispatch(&mut harness.app, None);
789 assert!(!result.is_error, "{:?}", result.message);
790 let markdown = harness
791 .app
792 .clipboard
793 .last_written_text()
794 .expect("clipboard payload")
795 .to_string();
796
797 let expected = normalize_export_time(include_str!("fixtures/export_conversation_baseline.md"));
798 crate::test_support::assert_byte_identical(
799 "baseline conversation export document",
800 &normalize_export_time(&markdown),
801 &expected,
802 );
803 }
804
805 #[test]
806 fn command_turn_export_matches_baseline_conversation_golden() {
807 let mut harness = ExportHarness::new();
808 harness.app.workspace = PathBuf::from(BASELINE_WORKSPACE);
809 harness.app.clipboard = ClipboardHandler::for_test(false, false);
810 harness.app.history.push(HistoryCell::User {
811 content: "Fix the flaky login test".to_string(),
812 });
813 harness.app.history.push(HistoryCell::Assistant {
814 content: "Fixed the login test.".to_string(),
815 streaming: false,
816 });
817 harness.app.runtime_turn_status = Some("completed".to_string());
818
819 let result = dispatch(&mut harness.app, Some("turn"));
820 assert!(!result.is_error, "{:?}", result.message);
821 let markdown = harness
822 .app
823 .clipboard
824 .last_written_text()
825 .expect("clipboard payload")
826 .to_string();
827
828 let expected = normalize_turn_generated_at(include_str!("fixtures/export_turn_baseline.md"));
829 crate::test_support::assert_byte_identical(
830 "baseline turn export document",
831 &normalize_turn_generated_at(&markdown),
832 &expected,
833 );
834 }
835
836 // ---------------------------------------------------------------------------
837 // FEAT-025 audit re-pass (finding G1): the first golden covered only the
838 // `RestorePointProjection::None` state and the authoritative transcript, so the
839 // `Recorded` restore table, the correlation lines, and the visible-history
840 // fallback were still asserted by hand-written expectations - the same class of
841 // weakness the first audit raised.
842 //
843 // These two goldens were captured the same way from baseline `3f3aa9ed7`:
844 // `fixtures/export_history_fallback_recorded_baseline.md` and
845 // `fixtures/export_correlation_recorded_baseline.md`. Snapshot commits carry a
846 // wall-clock author date, so the snapshot id and the `Recorded (UTC)` cell are
847 // normalised; the table structure, id truncation, correlation wording, the
848 // ambiguity warning, and the no-match line are compared verbatim.
849 // ---------------------------------------------------------------------------
850
851 /// Workspace **basename** pinned by the recorded-restore goldens. The export
852 /// header prints only the basename, so a per-test directory with this name
853 /// reproduces the captured document exactly while staying inside `TempDir`.
854 const BASELINE_RECORDED_WORKSPACE: &str = "f025-golden-workspace";
855
856 /// Seed the three snapshots the recorded goldens were captured with.
857 ///
858 /// Creation order matters: `SnapshotRepo::list` returns newest first, so this
859 /// yields `pre-turn:3`, `tool:call-1`, `pre-turn:2` in the table, and two
860 /// `pre-turn` entries sharing a prompt snippet - which is what makes the
861 /// correlation line ambiguous.
862 fn seed_baseline_restore_points(workspace: &Path) {
863 let repo = SnapshotRepo::open_or_init(workspace).expect("open side repo");
864 repo.snapshot("pre-turn:2: Fix the login test")
865 .expect("snapshot 1");
866 repo.snapshot("tool:call-1").expect("snapshot 2");
867 repo.snapshot("pre-turn:3: Fix the login test")
868 .expect("snapshot 3");
869 }
870
871 fn recorded_workspace(harness: &ExportHarness) -> PathBuf {
872 let workspace = harness.temp.path().join(BASELINE_RECORDED_WORKSPACE);
873 std::fs::create_dir_all(&workspace).expect("workspace");
874 seed_baseline_restore_points(&workspace);
875 workspace
876 }
877
878 #[test]
879 fn command_history_fallback_export_matches_baseline_recorded_golden() {
880 let mut harness = ExportHarness::new();
881 let workspace = recorded_workspace(&harness);
882 harness.app.workspace = workspace;
883 harness.app.current_session_id = Some("session-fallback".to_string());
884 harness.app.clipboard = ClipboardHandler::for_test(false, false);
885 harness.app.history.push(HistoryCell::User {
886 content: "Fix the login test".to_string(),
887 });
888 harness.app.history.push(HistoryCell::Assistant {
889 content: "Done.".to_string(),
890 streaming: false,
891 });
892
893 let result = dispatch(&mut harness.app, None);
894 assert!(!result.is_error, "{:?}", result.message);
895 let markdown = harness
896 .app
897 .clipboard
898 .last_written_text()
899 .expect("clipboard payload")
900 .to_string();
901
902 let expected = normalize_recorded_export(include_str!(
903 "fixtures/export_history_fallback_recorded_baseline.md"
904 ));
905 crate::test_support::assert_byte_identical(
906 "baseline history-fallback recorded export document",
907 &normalize_recorded_export(&markdown),
908 &expected,
909 );
910 }
911
912 #[test]
913 fn command_correlation_export_matches_baseline_recorded_golden() {
914 let mut harness = ExportHarness::new();
915 let workspace = recorded_workspace(&harness);
916 harness.app.workspace = workspace;
917 harness.app.current_session_id = Some("session-correlated".to_string());
918 harness.app.clipboard = ClipboardHandler::for_test(false, false);
919 harness.app.api_messages = std::sync::Arc::new(vec![
920 text_message(Role::User, "Fix the login test"),
921 text_message(Role::Assistant, "Working on it."),
922 text_message(Role::User, "unrelated question"),
923 ]);
924
925 let result = dispatch(&mut harness.app, None);
926 assert!(!result.is_error, "{:?}", result.message);
927 let markdown = harness
928 .app
929 .clipboard
930 .last_written_text()
931 .expect("clipboard payload")
932 .to_string();
933
934 let expected = normalize_recorded_export(include_str!(
935 "fixtures/export_correlation_recorded_baseline.md"
936 ));
937 crate::test_support::assert_byte_identical(
938 "baseline correlated recorded export document",
939 &normalize_recorded_export(&markdown),
940 &expected,
941 );
942 }
943
944 #[test]
945 fn export_entry_registers_through_portable_bridge_with_exact_metadata() {
946 use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
947
948 let command = crate::commands::registry()
949 .get("export")
950 .expect("/export must be registered");
951 assert_eq!(command.info().name, "export");
952 assert_eq!(command.info().aliases, &["daochu"]);
953 assert_eq!(
954 command.info().usage,
955 "/export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]"
956 );
957 assert!(
958 crate::commands::registry().get("daochu").is_some(),
959 "the /daochu alias must resolve to /export"
960 );
961 let handler = command
962 .contextual_handler()
963 .expect("/export must register through the portable bridge");
964 let CommandHandler::Contextual { capabilities, .. } = handler else {
965 panic!("/export must be contextual")
966 };
967 assert_eq!(
968 capabilities,
969 CommandCapabilities::SESSION_EXPORT,
970 "/export declares export authority only"
971 );
972 }
973
974 #[test]
975 fn command_clipboard_export_preserves_structure_and_redacts_secrets() {
976 let mut harness = ExportHarness::new();
977 let app = &mut harness.app;
978 app.current_session_id = Some("session-123456789".to_string());
979 app.api_messages = std::sync::Arc::new(vec![
980 Message {
981 role: Role::System,
982 content: vec![ContentBlock::Text {
983 text: "hidden policy must never export".to_string(),
984 cache_control: None,
985 }],
986 },
987 Message {
988 role: Role::User,
989 content: vec![ContentBlock::Text {
990 text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(),
991 cache_control: None,
992 }],
993 },
994 Message {
995 role: Role::Assistant,
996 content: vec![
997 ContentBlock::Thinking {
998 thinking: "private chain of thought".to_string(),
999 signature: Some("signature-secret".to_string()),
1000 state: None,
1001 },
1002 ContentBlock::ToolUse {
1003 execution_id: None,
1004 id: "call-1".to_string(),
1005 name: "fetch_url".to_string(),
1006 input: serde_json::json!({
1007 "url": "https://alice:password@example.com/path?token=very-secret&ok=1",
1008 "api_key": "literal-api-secret",
1009 "nested": {"authorization": "Bearer abcdefghijklmnop"},
1010 }),
1011 caller: Some(ToolCaller {
1012 caller_type: "code_execution_20250825".to_string(),
1013 tool_id: Some("server-tool-1".to_string()),
1014 }),
1015 thought_signature: None,
1016 },
1017 ],
1018 },
1019 Message {
1020 role: Role::User,
1021 content: vec![ContentBlock::ToolResult {
1022 execution_id: None,
1023 tool_use_id: "call-1".to_string(),
1024 content: "Authorization: Bearer another-secret-token\nresult ok".to_string(),
1025 is_error: Some(false),
1026 content_blocks: Some(vec![serde_json::json!({
1027 "image": "https://example.com/a.png?api_key=hidden",
1028 "session_token": "session-secret",
1029 })]),
1030 }],
1031 },
1032 Message {
1033 role: Role::Assistant,
1034 content: vec![ContentBlock::ImageUrl {
1035 image_url: ImageUrlContent {
1036 url: "data:image/png;base64,very-secret-image-data".to_string(),
1037 },
1038 }],
1039 },
1040 ]);
1041 {
1042 let mut todos = app.todos.try_lock().expect("todos lock");
1043 todos.add(
1044 "export projection".to_string(),
1045 crate::tools::todo::TodoStatus::InProgress,
1046 );
1047 }
1048 app.cycle_effort();
1049 let work_before = app.work_state_snapshot().expect("Work snapshot");
1050
1051 let result = dispatch(app, None);
1052
1053 assert!(!result.is_error, "{:?}", result.message);
1054 assert!(
1055 result
1056 .message
1057 .as_deref()
1058 .unwrap_or_default()
1059 .contains("local clipboard")
1060 );
1061 let markdown = app
1062 .clipboard
1063 .last_written_text()
1064 .expect("clipboard payload");
1065 let system = markdown.find("## 1. system").expect("system role");
1066 let user = markdown.find("## 2. user").expect("user role");
1067 let assistant = markdown.find("## 3. assistant").expect("assistant role");
1068 let tool_result = markdown.find("## 4. user").expect("tool-result role");
1069 assert!(system < user && user < assistant && assistant < tool_result);
1070 assert!(markdown.contains("[internal context omitted]"));
1071 assert!(markdown.contains("call-1"));
1072 assert!(markdown.contains("fetch_url"));
1073 assert!(markdown.contains("server-tool-1"));
1074 assert!(markdown.contains("[internal reasoning and signature omitted]"));
1075 assert!(markdown.contains("[redacted]"));
1076 assert!(markdown.contains("https://***:***@example.com/path?token=***&ok=1"));
1077 assert!(markdown.contains("Reference omitted (inline or local image payload)"));
1078 let workspace_path = harness.temp.path().to_string_lossy().into_owned();
1079 for forbidden in [
1080 "hidden policy must never export",
1081 "private chain of thought",
1082 "signature-secret",
1083 "literal-api-secret",
1084 "very-secret",
1085 "another-secret-token",
1086 "session-secret",
1087 "very-secret-image-data",
1088 "\u{1b}[31m",
1089 workspace_path.as_str(),
1090 ] {
1091 assert!(
1092 !markdown.contains(forbidden),
1093 "leaked {forbidden:?}: {markdown}"
1094 );
1095 }
1096 assert_eq!(
1097 app.work_state_snapshot()
1098 .expect("Work snapshot after export"),
1099 work_before,
1100 "export must not mutate Work"
1101 );
1102 }
1103
1104 #[test]
1105 fn command_clipboard_reports_ssh_terminal_client_and_failure_honestly() {
1106 let mut harness = ExportHarness::new();
1107 let app = &mut harness.app;
1108 app.clipboard = ClipboardHandler::for_test(true, true);
1109 let ssh = dispatch(app, Some("clipboard"));
1110 assert!(!ssh.is_error, "{:?}", ssh.message);
1111 assert!(
1112 ssh.message
1113 .as_deref()
1114 .unwrap_or_default()
1115 .contains("terminal-client clipboard over SSH")
1116 );
1117 assert!(
1118 ssh.message
1119 .as_deref()
1120 .unwrap_or_default()
1121 .contains("last-copy.md"),
1122 "success must name the backup copy: {:?}",
1123 ssh.message
1124 );
1125
1126 app.clipboard = ClipboardHandler::unavailable_for_test(false);
1127 let failed = dispatch(app, Some("clipboard"));
1128 assert!(failed.is_error);
1129 let message = failed.message.as_deref().unwrap_or_default();
1130 assert!(
1131 message.contains("The full export was written to"),
1132 "{message}"
1133 );
1134 assert!(message.contains("last-copy.md"), "{message}");
1135 assert!(message.contains("/export file <path>"), "{message}");
1136 assert!(!harness.temp.path().join("chat_export.md").exists());
1137 assert!(
1138 harness
1139 .temp
1140 .path()
1141 .join("home/exports/last-copy.md")
1142 .exists()
1143 );
1144 }
1145
1146 #[test]
1147 fn command_file_export_is_workspace_relative_private_and_no_overwrite_by_default() {
1148 let mut harness = ExportHarness::new();
1149 let workspace = harness.temp.path().join("workspace");
1150 std::fs::create_dir_all(&workspace).expect("workspace");
1151 let app = &mut harness.app;
1152 app.workspace = workspace.clone();
1153 app.api_messages_mut().push(Message {
1154 role: Role::User,
1155 content: vec![ContentBlock::Text {
1156 text: "first export".to_string(),
1157 cache_control: None,
1158 }],
1159 });
1160
1161 let first = dispatch(app, Some("file transcript.md"));
1162 assert!(!first.is_error, "{:?}", first.message);
1163 let path = std::fs::canonicalize(&workspace)
1164 .unwrap()
1165 .join("transcript.md");
1166 let original = std::fs::read_to_string(&path).expect("first export");
1167 assert!(original.contains("first export"));
1168 #[cfg(unix)]
1169 {
1170 use std::os::unix::fs::PermissionsExt;
1171 assert_eq!(
1172 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1173 0o600
1174 );
1175 }
1176
1177 app.api_messages_mut()[0].content = vec![ContentBlock::Text {
1178 text: "replacement export".to_string(),
1179 cache_control: None,
1180 }];
1181 let refused = dispatch(app, Some("transcript.md"));
1182 assert!(refused.is_error);
1183 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
1184
1185 let forced = dispatch(app, Some("file --force transcript.md"));
1186 assert!(!forced.is_error, "{:?}", forced.message);
1187 assert!(
1188 std::fs::read_to_string(&path)
1189 .unwrap()
1190 .contains("replacement export")
1191 );
1192 }
1193
1194 #[test]
1195 fn command_file_export_rejects_traversal_missing_parent_and_invalid_usage() {
1196 let mut harness = ExportHarness::new();
1197 let workspace = harness.temp.path().join("workspace");
1198 std::fs::create_dir_all(&workspace).expect("workspace");
1199 let app = &mut harness.app;
1200 app.workspace = workspace.clone();
1201
1202 for arg in [
1203 "file ../outside.md",
1204 "file missing/export.md",
1205 "file",
1206 "file --force",
1207 "clipboard extra.md",
1208 "turn clipboard extra.md",
1209 ] {
1210 let result = dispatch(app, Some(arg));
1211 assert!(result.is_error, "{arg}: {:?}", result.message);
1212 }
1213 assert!(!harness.temp.path().join("outside.md").exists());
1214 }
1215
1216 #[cfg(unix)]
1217 #[test]
1218 fn command_file_export_rejects_symlink_leaf_and_ancestor() {
1219 use std::os::unix::fs::symlink;
1220
1221 let mut harness = ExportHarness::new();
1222 let workspace = harness.temp.path().join("workspace");
1223 std::fs::create_dir_all(&workspace).expect("workspace");
1224 let app = &mut harness.app;
1225 app.workspace = workspace.clone();
1226
1227 let real_file = workspace.join("real.md");
1228 std::fs::write(&real_file, "keep").expect("fixture file");
1229 let leaf = workspace.join("leaf.md");
1230 symlink(&real_file, &leaf).expect("leaf symlink");
1231 let leaf_result = dispatch(app, Some(&format!("file --force {}", leaf.display())));
1232 assert!(leaf_result.is_error, "{:?}", leaf_result.message);
1233 assert_eq!(std::fs::read_to_string(&real_file).unwrap(), "keep");
1234
1235 let real_dir = workspace.join("real-dir");
1236 std::fs::create_dir(&real_dir).expect("real dir");
1237 let linked_dir = workspace.join("linked-dir");
1238 symlink(&real_dir, &linked_dir).expect("dir symlink");
1239 let ancestor_result = dispatch(
1240 app,
1241 Some(&format!("file {}", linked_dir.join("out.md").display())),
1242 );
1243 assert!(ancestor_result.is_error, "{:?}", ancestor_result.message);
1244 assert!(!real_dir.join("out.md").exists());
1245 }
1246
1247 #[test]
1248 fn command_turn_export_supports_clipboard_and_safe_legacy_file_destination() {
1249 let mut harness = ExportHarness::new();
1250 let app = &mut harness.app;
1251 app.history.push(HistoryCell::User {
1252 content: "Fix the flaky login test".to_string(),
1253 });
1254 app.history.push(HistoryCell::Assistant {
1255 content: "Fixed the login test.".to_string(),
1256 streaming: false,
1257 });
1258 app.runtime_turn_status = Some("completed".to_string());
1259
1260 let clipboard = dispatch(app, Some("turn"));
1261 assert!(!clipboard.is_error, "{:?}", clipboard.message);
1262 assert!(
1263 app.clipboard
1264 .last_written_text()
1265 .unwrap_or_default()
1266 .contains("# Turn handoff")
1267 );
1268
1269 let path = harness.temp.path().join("handoff.md");
1270 let file = dispatch(app, Some(&format!("turn {}", path.display())));
1271 assert!(!file.is_error, "{:?}", file.message);
1272 assert!(
1273 std::fs::read_to_string(&path)
1274 .unwrap()
1275 .contains("Fix the flaky login test")
1276 );
1277 let refused = dispatch(app, Some(&format!("turn {}", path.display())));
1278 assert!(refused.is_error);
1279 }
1280
1281 #[test]
1282 fn command_export_does_not_create_a_snapshot_repo_for_a_fresh_workspace() {
1283 let mut harness = ExportHarness::new();
1284 let workspace = harness.temp.path().join("workspace");
1285 std::fs::create_dir_all(&workspace).expect("workspace");
1286 let app = &mut harness.app;
1287 app.workspace = workspace.clone();
1288 let before = crate::snapshot::snapshot_git_dir(&workspace).expect("snapshot path");
1289 assert!(!before.exists(), "precondition: no side repo yet");
1290
1291 let result = dispatch(app, Some("clipboard"));
1292 assert!(!result.is_error, "{:?}", result.message);
1293
1294 assert!(
1295 !crate::snapshot::snapshot_git_dir(&workspace)
1296 .expect("snapshot path")
1297 .exists(),
1298 "export must never create the side repo"
1299 );
1300 }
1301
1302 #[test]
1303 fn adapter_projects_metadata_fallbacks_without_session_or_filename() {
1304 let mut harness = ExportHarness::new();
1305 // No session id: the baseline label is `unsaved`. A workspace whose final
1306 // component is `..` has no `file_name()`, so the label falls back too.
1307 harness.app.current_session_id = None;
1308 harness.app.workspace = harness.temp.path().join("..");
1309
1310 let projection = conversation_projection(&mut harness.app);
1311
1312 assert_eq!(projection.metadata.session_label, "unsaved");
1313 assert_eq!(projection.metadata.workspace_name, "workspace");
1314 assert_eq!(projection.metadata.message_count, 0);
1315 }
1316
1317 #[test]
1318 fn adapter_bounds_restore_points_to_the_latest_hundred_newest_first() {
1319 let mut harness = ExportHarness::new();
1320 let workspace = harness.temp.path().join("workspace");
1321 std::fs::create_dir_all(&workspace).expect("workspace");
1322 harness.app.workspace = workspace.clone();
1323 let repo = SnapshotRepo::open_or_init(&workspace).expect("open side repo");
1324
1325 // 101 recorded points prove the adapter's latest-100 window: exactly one
1326 // entry (the oldest) must be dropped.
1327 for sequence in 0..=100 {
1328 repo.snapshot(&format!("pre-turn:{sequence}: prompt {sequence}"))
1329 .expect("record snapshot");
1330 }
1331
1332 let projection = conversation_projection(&mut harness.app);
1333
1334 let RestorePointProjection::Recorded { snapshots } = &projection.restore_points else {
1335 panic!("recorded restore points expected");
1336 };
1337 assert_eq!(snapshots.len(), 100, "the adapter lists at most 100 points");
1338 assert_eq!(
1339 snapshots.first().map(|snapshot| snapshot.sequence),
1340 Some(Some(100)),
1341 "newest point is first"
1342 );
1343 assert_eq!(
1344 snapshots.last().map(|snapshot| snapshot.sequence),
1345 Some(Some(1)),
1346 "the window stops at the 100th newest point"
1347 );
1348 assert!(
1349 snapshots
1350 .iter()
1351 .all(|snapshot| snapshot.sequence != Some(0)),
1352 "the oldest (101st) point must not cross the window"
1353 );
1354 }
1355
1356 #[test]
1357 fn adapter_rejects_directory_destination_and_parent_not_a_directory() {
1358 let mut harness = ExportHarness::new();
1359 let workspace = canonical_workspace(&harness);
1360 harness.app.workspace = workspace.clone();
1361
1362 let directory = workspace.join("existing-dir");
1363 std::fs::create_dir(&directory).expect("directory target");
1364 let refused = write_file(&mut harness.app, &directory, b"x", true).unwrap_err();
1365 assert!(
1366 refused.contains("refusing to replace a non-regular file"),
1367 "{refused}"
1368 );
1369 assert!(directory.is_dir(), "the directory must be untouched");
1370
1371 let file_parent = workspace.join("not-a-dir");
1372 std::fs::write(&file_parent, "file").expect("file parent");
1373 let parent_error =
1374 write_file(&mut harness.app, &file_parent.join("out.md"), b"x", false).unwrap_err();
1375 assert!(
1376 parent_error.contains("parent is not a directory"),
1377 "{parent_error}"
1378 );
1379 assert!(!file_parent.join("out.md").exists());
1380 }
1381
1382 #[test]
1383 fn adapter_resolves_absolute_paths_and_keeps_the_lexical_fallback() {
1384 let mut harness = ExportHarness::new();
1385 let workspace = harness.temp.path().join("workspace");
1386 std::fs::create_dir_all(&workspace).expect("workspace");
1387 harness.app.workspace = workspace.clone();
1388 let canonical_workspace = std::fs::canonicalize(&workspace).expect("canonical workspace");
1389
1390 // A raw workspace-absolute path rebases onto the resolved workspace.
1391 assert_eq!(
1392 resolve(
1393 &mut harness.app,
1394 &workspace.join("abs.md").to_string_lossy()
1395 )
1396 .expect("raw absolute"),
1397 canonical_workspace.join("abs.md")
1398 );
1399 // A canonicalized workspace-absolute path rebases the same way.
1400 assert_eq!(
1401 resolve(
1402 &mut harness.app,
1403 &canonical_workspace.join("nested/abs.md").to_string_lossy()
1404 )
1405 .expect("canonical absolute"),
1406 canonical_workspace.join("nested/abs.md")
1407 );
1408 // A path outside the workspace stays absolute and is not rebased.
1409 let outside = harness.temp.path().join("outside.md");
1410 assert_eq!(
1411 resolve(&mut harness.app, &outside.to_string_lossy()).expect("outside absolute"),
1412 outside
1413 );
1414
1415 // A workspace that no longer exists falls back to the lexical path instead
1416 // of failing canonicalization.
1417 let missing = harness.temp.path().join("gone");
1418 harness.app.workspace = missing.clone();
1419 assert_eq!(
1420 resolve(&mut harness.app, "relative.md").expect("lexical fallback"),
1421 missing.join("relative.md")
1422 );
1423 }
1424
1424 lines RUST