返回 CodeWhale
session_export_surface_tests.rs
根目录 / crates / tui / src / commands / session_export_surface_tests.rs
1 //! FEAT-025 Phase 5: public command-surface parity coverage for `/export`.
2 //!
3 //! Phase 4 proved handler/rendering/adapter parity with fake facets and
4 //! relocated host regressions. This module proves the *observable command
5 //! surface* did not move when registration crossed the portable bridge:
6 //!
7 //! * registry metadata (name, alias, usage) and registry position,
8 //! * the `description_key` -> catalog bridge and its English/localized text,
9 //! * palette and slash-completion discovery, including the `/daochu` alias,
10 //! * least authority: exactly `SESSION_EXPORT` and no presentation facet,
11 //! * canonical-name/alias dispatch equivalence through the public `execute`
12 //! seam, with exact receipts and exact visible errors.
13 //!
14 //! Like the Phase 3 host regressions, this file deliberately lives at the
15 //! `commands` root — outside `groups/session`, which FEAT-043 moves into
16 //! `codewhale-commands`. No real terminal clipboard, GUI, or device is needed:
17 //! the harness uses the deterministic in-process clipboard and temporary
18 //! filesystem fixtures.
19
20 use tempfile::TempDir;
21
22 use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
23
24 use crate::commands::session_export_test_support::{
25 assert_only_export_facet_exposed, normalize_export_time,
26 };
27 use crate::commands::traits::CommandDiscovery;
28 use crate::commands::{CommandResult, execute};
29 use crate::config::{ApiProvider, Config};
30 use crate::test_support::{EnvVarGuard, TestEnvLock};
31 use crate::tui::app::{App, TuiOptions};
32 use crate::tui::clipboard::ClipboardHandler;
33 use crate::tui::command_palette;
34 use codewhale_localization::Locale;
35 use codewhale_models::{ContentBlock, Message, Role};
36
37 const EXPORT_NAME: &str = "export";
38 const EXPORT_ALIAS: &str = "daochu";
39 const EXPORT_USAGE: &str =
40 "/export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]";
41 // Pinned to the authoritative catalog values (`crates/localization/locales/{en,fr}.json`,
42 // key `CmdExportDescription`). The `description_key` bridge must keep resolving the
43 // `/export` metadata to this catalog entry; the wording itself is owned by the catalog.
44 const EXPORT_ENGLISH: &str = "Copy a safe export, or write it to a file";
45 const EXPORT_FRENCH: &str =
46 "Copier un export sûr de la conversation, ou l'écrire dans un fichier explicite";
47
48 /// Shared host-isolated harness (same isolation order as the Phase 3
49 /// `ExportHarness`: env guards, then the lock, then the temporary directory).
50 struct SurfaceHarness {
51 app: App,
52 _home: EnvVarGuard,
53 _codewhale_home: EnvVarGuard,
54 _env_lock: TestEnvLock,
55 temp: TempDir,
56 }
57
58 impl SurfaceHarness {
59 fn new() -> Self {
60 let env_lock = crate::test_support::lock_test_env();
61 let temp = TempDir::new().expect("tempdir");
62 let home = temp.path().join("home");
63 std::fs::create_dir_all(&home).expect("home dir");
64 let _home = EnvVarGuard::set("HOME", &home);
65 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
66 let options = TuiOptions {
67 skills_dir: temp.path().join("skills"),
68 memory_path: temp.path().join("memory.md"),
69 notes_path: temp.path().join("notes.txt"),
70 mcp_config_path: temp.path().join("mcp.json"),
71 ..crate::test_support::test_tui_options(temp.path())
72 };
73 let app = App::new(options, &Config::default());
74 Self {
75 app,
76 _home,
77 _codewhale_home,
78 _env_lock: env_lock,
79 temp,
80 }
81 }
82
83 fn last_copy_path(&self) -> std::path::PathBuf {
84 self.temp
85 .path()
86 .join("home")
87 .join("exports")
88 .join("last-copy.md")
89 }
90 }
91
92 fn text_message(role: Role, text: &str) -> Message {
93 Message {
94 role,
95 content: vec![ContentBlock::Text {
96 text: text.to_string(),
97 cache_control: None,
98 }],
99 }
100 }
101
102 fn export_info() -> &'static crate::commands::CommandInfo {
103 crate::commands::get_command_info(EXPORT_NAME).expect("/export must be registered")
104 }
105
106 fn result_message(result: &CommandResult) -> &str {
107 result.message.as_deref().unwrap_or_default()
108 }
109
110 #[test]
111 fn export_registration_metadata_and_registry_position_are_unchanged() {
112 let info = export_info();
113 assert_eq!(info.name, EXPORT_NAME);
114 assert_eq!(info.aliases, &[EXPORT_ALIAS]);
115 assert_eq!(info.usage, EXPORT_USAGE);
116
117 // The alias resolves to the same registry entry and canonical metadata.
118 let registry = crate::commands::registry();
119 let via_alias = registry
120 .get(EXPORT_ALIAS)
121 .expect("/daochu must resolve to the export entry");
122 assert_eq!(via_alias.info().name, EXPORT_NAME);
123 assert_eq!(via_alias.info().usage, EXPORT_USAGE);
124 assert!(
125 registry.get(EXPORT_NAME).is_some(),
126 "canonical /export must remain registered"
127 );
128
129 // Registry order is preserved: remote-env -> export -> structcopy.
130 let names: Vec<&str> = crate::commands::command_infos()
131 .iter()
132 .map(|info| info.name)
133 .collect();
134 let position = |name: &str| {
135 names
136 .iter()
137 .position(|candidate| *candidate == name)
138 .unwrap_or_else(|| panic!("{name} must be registered; found {names:?}"))
139 };
140 assert!(
141 position("remote-env") < position(EXPORT_NAME),
142 "export must stay after remote-env in the session group order"
143 );
144 assert!(
145 position(EXPORT_NAME) < position("structcopy"),
146 "export must stay before structcopy in the session group order"
147 );
148 }
149
150 #[test]
151 fn export_declares_exactly_session_export_without_presentation_authority() {
152 let command = crate::commands::registry()
153 .get(EXPORT_NAME)
154 .expect("/export must be registered");
155 let handler = command
156 .contextual_handler()
157 .expect("/export must register through the portable bridge");
158 let CommandHandler::Contextual { capabilities, .. } = handler else {
159 panic!("/export must be a contextual handler")
160 };
161 assert_eq!(
162 capabilities,
163 CommandCapabilities::SESSION_EXPORT,
164 "/export declares exactly SESSION_EXPORT"
165 );
166 assert!(
167 !capabilities.contains(CommandCapabilities::PRESENTATION),
168 "export must not request presentation authority"
169 );
170
171 // The restricted projection exposes only the declared facet. The helper
172 // destructures all sixteen `ContextParts` slots, so this is exhaustive
173 // rather than a spot-check of the fields listed below by hand.
174 let mut harness = SurfaceHarness::new();
175 let mut bundle = harness.app.command_contexts();
176 let export_only = bundle
177 .contexts(CommandCapabilities::SESSION_EXPORT)
178 .into_parts();
179 assert_only_export_facet_exposed(export_only);
180 }
181
182 #[test]
183 fn export_description_bridge_preserves_english_localized_and_discovery_metadata() {
184 let info = export_info();
185
186 // `description_key` -> `key_to_message_id` -> catalog: English reference
187 // and the shipped French pack both resolve through the same bridge.
188 assert_eq!(&*info.description_for(Locale::En), EXPORT_ENGLISH);
189 assert_eq!(&*info.description_for(Locale::Fr), EXPORT_FRENCH);
190
191 // Palette text keeps the canonical description and advertises the alias.
192 let palette = info.palette_description_for(Locale::En);
193 assert!(
194 palette.contains(EXPORT_ENGLISH),
195 "palette description must keep the catalog text: {palette}"
196 );
197 assert!(
198 palette.contains(EXPORT_ALIAS),
199 "palette description must keep the alias: {palette}"
200 );
201
202 // Discovery classification and visibility are part of the surface.
203 assert_eq!(info.discovery(), CommandDiscovery::Primary);
204 assert!(!info.is_unlisted());
205 assert!(info.show_in_empty_discovery());
206 assert!(info.show_in_slash_completion("/exp"));
207 assert!(info.requires_argument());
208 }
209
210 #[test]
211 fn export_is_discoverable_by_name_and_alias_in_palette_and_slash_completion() {
212 // Pure discovery: a workspace path is all these surfaces need, so this test
213 // avoids the shared environment lock the host fixtures require.
214 let workspace_dir = TempDir::new().expect("tempdir");
215 let workspace = workspace_dir.path();
216 let skills_dir = workspace.join("skills");
217 let mcp_config = workspace.join("mcp.json");
218
219 let entries = command_palette::build_entries(
220 Locale::En,
221 &skills_dir,
222 false,
223 workspace,
224 &mcp_config,
225 None,
226 );
227 let export_row = entries
228 .iter()
229 .find(|entry| entry.label == "/export")
230 .expect("/export must appear in the command palette");
231 assert!(
232 export_row.description.contains(EXPORT_ENGLISH),
233 "palette row must carry the catalog description: {}",
234 export_row.description
235 );
236 assert!(
237 export_row.description.contains(EXPORT_ALIAS),
238 "palette row must advertise /daochu: {}",
239 export_row.description
240 );
241
242 let by_prefix = crate::tui::widgets::slash_completion_hints(
243 "/exp",
244 64,
245 &[],
246 Locale::En,
247 Some(workspace),
248 ApiProvider::Deepseek,
249 );
250 assert!(
251 by_prefix.iter().any(|hint| hint.name == "/export"),
252 "/exp must complete to /export"
253 );
254
255 let by_alias = crate::tui::widgets::slash_completion_hints(
256 "/daoc",
257 64,
258 &[],
259 Locale::En,
260 Some(workspace),
261 ApiProvider::Deepseek,
262 );
263 let alias_row = by_alias
264 .iter()
265 .find(|hint| hint.name == "/export")
266 .expect("/daoc must surface the export command");
267 assert_eq!(
268 alias_row.alias_hint.as_deref(),
269 Some(EXPORT_ALIAS),
270 "slash completion must explain the alias match"
271 );
272
273 // The alias token is not a second registry entry.
274 assert!(
275 !entries.iter().any(|entry| entry.label == "/daochu"),
276 "the alias must not create a duplicate palette row"
277 );
278 }
279
280 #[test]
281 fn public_dispatch_canonical_name_and_alias_are_byte_equivalent() {
282 let mut harness = SurfaceHarness::new();
283 let last_copy = harness.last_copy_path();
284 let app = &mut harness.app;
285 app.current_session_id = Some("session-987654321".to_string());
286 app.api_messages = std::sync::Arc::new(vec![
287 text_message(Role::User, "Please export this conversation"),
288 text_message(Role::Assistant, "Exported on request."),
289 ]);
290 app.clipboard = ClipboardHandler::for_test(false, false);
291
292 let canonical = execute("/export clipboard", app);
293 assert!(!canonical.is_error, "{:?}", canonical.message);
294 let canonical_markdown = app
295 .clipboard
296 .last_written_text()
297 .expect("canonical clipboard payload")
298 .to_string();
299 let canonical_recovery = std::fs::read_to_string(&last_copy).expect("canonical recovery copy");
300
301 // Reset the deterministic clipboard so the second dispatch records its own
302 // delivery; state is otherwise identical.
303 app.clipboard = ClipboardHandler::for_test(false, false);
304
305 let alias = execute("/daochu clipboard", app);
306 assert!(!alias.is_error, "{:?}", alias.message);
307 let alias_markdown = app
308 .clipboard
309 .last_written_text()
310 .expect("alias clipboard payload")
311 .to_string();
312 let alias_recovery = std::fs::read_to_string(&last_copy).expect("alias recovery copy");
313
314 assert_eq!(
315 result_message(&canonical),
316 result_message(&alias),
317 "canonical and alias receipts must be identical"
318 );
319 assert_eq!(
320 normalize_export_time(&canonical_markdown),
321 normalize_export_time(&alias_markdown),
322 "canonical and alias clipboard payloads must be identical"
323 );
324 assert_eq!(
325 normalize_export_time(&canonical_recovery),
326 normalize_export_time(&alias_recovery),
327 "canonical and alias recovery copies must be identical"
328 );
329 assert!(canonical_markdown.contains("# Codewhale conversation export"));
330 assert!(canonical_markdown.contains("Please export this conversation"));
331 }
332
333 #[test]
334 fn public_dispatch_file_receipts_and_usage_errors_are_exact() {
335 let mut harness = SurfaceHarness::new();
336 let workspace = harness.temp.path().join("workspace");
337 std::fs::create_dir_all(&workspace).expect("workspace");
338 let app = &mut harness.app;
339 app.workspace = workspace.clone();
340 app.api_messages = std::sync::Arc::new(vec![text_message(Role::User, "file export body")]);
341
342 let resolved = std::fs::canonicalize(&workspace)
343 .expect("canonical workspace")
344 .join("transcript.md");
345
346 let first = execute("/export file transcript.md", app);
347 assert!(!first.is_error, "{:?}", first.message);
348 assert_eq!(
349 result_message(&first),
350 format!("Conversation exported to {}", resolved.display())
351 );
352
353 let refused = execute("/export file transcript.md", app);
354 assert!(refused.is_error, "{:?}", refused.message);
355 assert_eq!(
356 result_message(&refused),
357 format!(
358 "Error: Failed to export Conversation to {}: destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
359 resolved.display(),
360 resolved.display()
361 )
362 );
363
364 let forced = execute("/export file --force transcript.md", app);
365 assert!(!forced.is_error, "{:?}", forced.message);
366 assert_eq!(
367 result_message(&forced),
368 format!(
369 "Conversation exported to {} (overwrite explicitly allowed)",
370 resolved.display()
371 )
372 );
373
374 let usage = export_info().usage;
375 for (arg, reason) in [
376 ("file", "missing file path"),
377 ("file --force", "missing file path"),
378 ("clipboard extra.md", "clipboard does not accept a path"),
379 ] {
380 let result = execute(&format!("/export {arg}"), app);
381 assert!(result.is_error, "{arg} must be rejected");
382 assert_eq!(
383 result_message(&result),
384 format!("Error: {reason}. Usage: {usage}"),
385 "{arg} must keep the baseline usage error"
386 );
387 }
388 }
389
390 // ---------------------------------------------------------------------------
391 // FEAT-025 Phase 7 (Task 7.2): extraction-readiness and scope audits.
392 //
393 // These audits protect the D4/D5/D8/D9 ownership contract that the portable
394 // source must satisfy before FEAT-043 can physically move the export slice into
395 // `codewhale-commands`. They are source-level checks, not runtime behavior
396 // tests, and they live at the `commands` root outside the movable group.
397 // ---------------------------------------------------------------------------
398
399 /// Production portion of the portable export source: everything before its
400 /// `#[cfg(test)]` module, with comments removed (full-line and trailing) so
401 /// rationale text (`Concrete `App`, clipboard, filesystem, ...`) is never
402 /// mistaken for an import or a concrete host symbol.
403 fn portable_production_source(source: &str) -> String {
404 let mut production = String::new();
405 for line in source.lines() {
406 if line.trim_start().starts_with("#[cfg(test)]") {
407 break;
408 }
409 let code = strip_line_comment(line);
410 if code.trim().is_empty() {
411 continue;
412 }
413 production.push_str(code.trim_end());
414 production.push('\n');
415 }
416 production
417 }
418
419 /// Strip a trailing `//` comment without touching `//` inside a string or
420 /// character literal.
421 ///
422 /// A naive strip would be wrong in both directions: portable code carries URL
423 /// literals such as `https://…`, and a marker string could hide a real token.
424 /// Lifetime ticks (`&'a str`) are not treated as character literals so a
425 /// following comment is still removed.
426 fn strip_line_comment(line: &str) -> &str {
427 let bytes = line.as_bytes();
428 let mut i = 0;
429 while i < bytes.len() {
430 match bytes[i] {
431 b'"' => {
432 i += 1;
433 while i < bytes.len() {
434 match bytes[i] {
435 b'\\' => i += 2,
436 b'"' => break,
437 _ => i += 1,
438 }
439 }
440 }
441 b'\'' => {
442 let opens_literal = matches!(
443 (bytes.get(i + 1), bytes.get(i + 2)),
444 (Some(b'\\'), _) | (Some(_), Some(b'\''))
445 );
446 if opens_literal {
447 i += 1;
448 while i < bytes.len() {
449 match bytes[i] {
450 b'\\' => i += 2,
451 b'\'' => break,
452 _ => i += 1,
453 }
454 }
455 }
456 }
457 b'/' if bytes.get(i + 1) == Some(&b'/') => return &line[..i],
458 _ => {}
459 }
460 i += 1;
461 }
462 line
463 }
464
465 /// Word-boundary identifier match.
466 ///
467 /// `production.contains("App")` fires on a legitimate `Append`, while an exact
468 /// equality check would miss `App::new`. `\b` matches the identifier token
469 /// only, so the audit fails for the right reason.
470 fn contains_identifier(haystack: &str, identifier: &str) -> bool {
471 let pattern = format!(r"\b{}\b", regex::escape(identifier));
472 regex::Regex::new(&pattern)
473 .expect("identifier regex")
474 .is_match(haystack)
475 }
476
477 /// The audits above are only as trustworthy as their scanners, so pin the two
478 /// behaviours that decide whether a finding is real: comment stripping must not
479 /// eat string literals, and identifier matching must not fire on a longer word.
480 #[test]
481 fn portable_source_audit_helpers_are_token_aware() {
482 assert_eq!(strip_line_comment("let x = 1; // App"), "let x = 1; ");
483 assert_eq!(strip_line_comment("// whole line"), "");
484 assert_eq!(
485 strip_line_comment("let url = \"https://example.test/a\";"),
486 "let url = \"https://example.test/a\";"
487 );
488 assert_eq!(
489 strip_line_comment("let c = '/'; let y = 1; // keep"),
490 "let c = '/'; let y = 1; "
491 );
492 // A lifetime tick must not swallow the rest of the line.
493 assert_eq!(
494 strip_line_comment("fn f<'a>(x: &'a str) {} // note"),
495 "fn f<'a>(x: &'a str) {} "
496 );
497
498 assert!(contains_identifier("let app = App::new();", "App"));
499 assert!(!contains_identifier("values.append(item);", "App"));
500 assert!(!contains_identifier("struct AppNew;", "App"));
501 assert!(contains_identifier("use ratatui::text::Line;", "ratatui"));
502 assert!(contains_identifier("unsafe { x }", "unsafe"));
503 }
504
505 #[test]
506 fn portable_export_source_has_no_host_dependency() {
507 let source = include_str!("groups/session/export.rs");
508 let production = portable_production_source(source);
509
510 // Only the external contract, the pure sanitizer, `serde_json`, `std`, and
511 // the temporary FEAT-037 `CommandResult` exception may be imported.
512 let allowed_use_prefixes = [
513 "use std::",
514 "use codewhale_command_contract",
515 "use codewhale_secrets",
516 "use serde_json",
517 "use super::CommandResult",
518 ];
519 for line in production.lines() {
520 let trimmed = line.trim_start();
521 if !trimmed.starts_with("use ") {
522 continue;
523 }
524 assert!(
525 allowed_use_prefixes
526 .iter()
527 .any(|prefix| trimmed.starts_with(prefix)),
528 "portable /export import is not allowed: {trimmed}"
529 );
530 }
531
532 // No concrete host module or type may appear in portable production code.
533 // Syntactic tokens are matched literally; bare identifiers are matched on
534 // word boundaries so `Append` cannot false-trigger on `App`.
535 for token in [
536 "use crate::",
537 "crate::tui",
538 "crate::client",
539 "crate::config",
540 "crate::snapshot",
541 "crate::session_manager",
542 "std::fs",
543 "std::net",
544 "std::process",
545 ] {
546 assert!(
547 !production.contains(token),
548 "portable /export must not reference {token}"
549 );
550 }
551 for identifier in [
552 "App",
553 "AppAction",
554 "ClipboardHandler",
555 "SnapshotRepo",
556 "SessionManager",
557 "HistoryCell",
558 "ContentBlock",
559 "OpenOptions",
560 "ratatui",
561 "crossterm",
562 ] {
563 assert!(
564 !contains_identifier(&production, identifier),
565 "portable /export must not reference {identifier}"
566 );
567 }
568
569 // The bounded FEAT-037 exception is exactly `CommandResult`: no action
570 // payload, deferred effect, or host receipt type may cross the boundary.
571 assert!(
572 !production.contains("super::App"),
573 "only CommandResult may cross the FEAT-037 boundary"
574 );
575 }
576
577 #[test]
578 fn portable_export_source_carries_no_hidden_authority() {
579 let source = include_str!("groups/session/export.rs");
580 let production = portable_production_source(source);
581
582 // Host authority must not hide behind a callback, boxed closure, erased
583 // receipt, or unsafe escape hatch.
584 for token in [
585 "Box<",
586 "dyn Fn",
587 "impl Fn",
588 "fn(&mut dyn",
589 "&mut dyn",
590 "transmute",
591 ] {
592 assert!(
593 !production.contains(token),
594 "portable /export must not contain {token}"
595 );
596 }
597 assert!(
598 !contains_identifier(&production, "unsafe"),
599 "portable /export must not contain unsafe"
600 );
601
602 // Missing authority fails with the exact safe error and never panics: the
603 // facet is destructured, not `.expect()`ed.
604 for token in [".expect(", ".unwrap(", "panic!(", "unreachable!(", "todo!("] {
605 assert!(
606 !production.contains(token),
607 "portable /export must not contain {token}"
608 );
609 }
610 assert!(
611 production.contains(
612 "return CommandResult::error(\"Command capability unavailable: session_export\".to_string());"
613 ),
614 "portable /export must keep the exact safe missing-authority error"
615 );
616 }
617
618 #[test]
619 fn shared_sanitizer_is_one_pure_acyclic_implementation() {
620 // Both `/export` and the still-legacy `/structcopy` consume the single
621 // implementation in the pure `codewhale-secrets` crate.
622 let export_source = include_str!("groups/session/export.rs");
623 let structcopy_source = include_str!("groups/session/structcopy.rs");
624 assert!(
625 export_source.contains("use codewhale_secrets::sanitize::"),
626 "portable /export must consume the shared sanitizer"
627 );
628 assert!(
629 structcopy_source.contains("use codewhale_secrets::sanitize::"),
630 "/structcopy must consume the same shared sanitizer"
631 );
632 // The module prose must name the new owner: a stale `export::<helper>` seam
633 // reference is exactly the comment drift inherited from PR #5525.
634 for stale in ["export::redact_json", "export::sanitize_text"] {
635 assert!(
636 !structcopy_source.contains(stale),
637 "/structcopy comments must not cite the removed {stale} seam"
638 );
639 }
640
641 // Fast local tripwire only. The authoritative check is the graph scan in
642 // `scripts/check-command-crate-boundaries.py`, which asserts that neither
643 // `codewhale-command-contract` nor `codewhale-secrets` reaches the TUI; this
644 // manifest read just fails sooner when someone edits the manifest by hand.
645 let secrets_manifest = std::fs::read_to_string(concat!(
646 env!("CARGO_MANIFEST_DIR"),
647 "/../secrets/Cargo.toml"
648 ))
649 .expect("the shared sanitizer crate manifest must be readable");
650 assert!(
651 !secrets_manifest.contains("codewhale-tui"),
652 "the shared sanitizer crate must not depend on codewhale-tui"
653 );
654
655 // There is no second sanitizer implementation hiding in the portable slice.
656 let production = portable_production_source(export_source);
657 for duplicated in [
658 "fn sanitize_text",
659 "fn redact_json",
660 "fn redact_url_for_display",
661 "fn strip_ansi",
662 ] {
663 assert!(
664 !production.contains(duplicated),
665 "portable /export must not duplicate {duplicated}"
666 );
667 }
668 }
669
670 #[test]
671 fn host_bound_fixtures_stay_outside_the_movable_group() {
672 // FEAT-043 moves `groups/session` into `codewhale-commands`. Real-host
673 // fixtures and the shared recovery writer must therefore stay at the
674 // `commands` root, and the portable slice must not embed host fixtures.
675 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
676 for relative in [
677 "src/commands/session_export_regression_tests.rs",
678 "src/commands/session_export_surface_tests.rs",
679 "src/commands/session_export_test_support.rs",
680 "src/commands/session_export_host.rs",
681 ] {
682 assert!(
683 manifest_dir.join(relative).exists(),
684 "host fixture {relative} must stay outside groups/session"
685 );
686 }
687
688 // The baseline-captured export goldens are host-bound (they include the
689 // host-derived metadata and redaction output only the real adapter can
690 // produce), so they must live at the `commands` root with the suites that
691 // consume them rather than inside the movable group.
692 for relative in [
693 "src/commands/fixtures/export_conversation_baseline.md",
694 "src/commands/fixtures/export_turn_baseline.md",
695 "src/commands/fixtures/export_history_fallback_recorded_baseline.md",
696 "src/commands/fixtures/export_correlation_recorded_baseline.md",
697 ] {
698 assert!(
699 manifest_dir.join(relative).exists(),
700 "baseline golden {relative} must stay outside groups/session"
701 );
702 }
703
704 let export_source = include_str!("groups/session/export.rs");
705 for host_fixture in [
706 "SessionExportAdapter",
707 "ExportHarness",
708 "tempfile",
709 "ClipboardHandler",
710 "SnapshotRepo",
711 "HistoryCell",
712 ] {
713 assert!(
714 !export_source.contains(host_fixture),
715 "portable /export must not embed the host fixture {host_fixture}"
716 );
717 }
718 }
719
719 lines RUST