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