返回 CodeWhale
session_export_test_support.rs
根目录 / crates / tui / src / commands / session_export_test_support.rs
1 //! Shared, host-bound test support for the FEAT-025 session-export slice.
2 //!
3 //! Lives at the `commands` root — outside `groups/session`, which FEAT-043
4 //! moves into `codewhale-commands` — so the public-surface and host-regression
5 //! suites reuse one implementation instead of drifting copies.
6
7 use codewhale_command_contract::handler::ContextParts;
8
9 use std::sync::OnceLock;
10
11 use regex::Regex;
12
13 /// Replace the host-derived `- Exported:` timestamp value so two exports of
14 /// identical state compare byte-for-byte. Production timestamp semantics stay
15 /// untouched (D3/observable-behavior rule: host metadata derivation is
16 /// preserved; tests control comparison, not the adapter).
17 pub(crate) fn normalize_export_time(markdown: &str) -> String {
18 let mut normalized = String::with_capacity(markdown.len());
19 for line in markdown.split_inclusive('\n') {
20 if let Some(rest) = line.strip_prefix("- Exported: ") {
21 let newline = if rest.ends_with('\n') { "\n" } else { "" };
22 normalized.push_str("- Exported: <time>");
23 normalized.push_str(newline);
24 } else {
25 normalized.push_str(line);
26 }
27 }
28 normalized
29 }
30
31 /// Replace the turn-handoff wall-clock generation stamp so a captured golden
32 /// compares byte-for-byte.
33 ///
34 /// `turn_handoff_markdown` stamps the header with `generated <now>`; the
35 /// renderer itself is TUI-owned and deliberately not migrated (D2), so tests
36 /// normalise the stamp instead of changing production timestamp semantics.
37 pub(crate) fn normalize_turn_generated_at(markdown: &str) -> String {
38 let mut normalized = String::with_capacity(markdown.len());
39 for line in markdown.split_inclusive('\n') {
40 let trimmed = line.trim_end_matches('\n');
41 let newline = &line[trimmed.len()..];
42 if let Some(head) = trimmed.strip_prefix("_Status: ")
43 && let Some((status, _)) = head.split_once(" \u{b7} generated ")
44 {
45 normalized.push_str("_Status: ");
46 normalized.push_str(status);
47 normalized.push_str(" \u{b7} generated <timestamp>_");
48 normalized.push_str(newline);
49 continue;
50 }
51 normalized.push_str(line);
52 }
53 normalized
54 }
55
56 /// Normalise the two volatile fields a recorded-restore-point export carries:
57 /// the snapshot id (a git SHA over a commit whose date is wall-clock) and the
58 /// `Recorded (UTC)` table cell.
59 ///
60 /// Snapshot commits are created with `git commit-tree` and no pinned
61 /// author/committer date, so both the SHA and the timestamp change on every run.
62 /// Everything else in the document - the table structure, the 12-character id
63 /// truncation, the correlation wording, the ambiguity warning, and the
64 /// no-match line - is compared verbatim.
65 pub(crate) fn normalize_snapshot_identity(markdown: &str) -> String {
66 fn snapshot_id_regex() -> &'static Regex {
67 static RE: OnceLock<Regex> = OnceLock::new();
68 RE.get_or_init(|| Regex::new(r"`[0-9a-f]{12}`").expect("snapshot-id regex"))
69 }
70 fn recorded_time_regex() -> &'static Regex {
71 static RE: OnceLock<Regex> = OnceLock::new();
72 RE.get_or_init(|| {
73 Regex::new(r"\| \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \|").expect("recorded-time regex")
74 })
75 }
76
77 let with_ids = snapshot_id_regex().replace_all(markdown, "`<snapshot-id>`");
78 recorded_time_regex()
79 .replace_all(&with_ids, "| <recorded> |")
80 .into_owned()
81 }
82
83 /// Normalisation for a document captured from the recorded-restore-point state:
84 /// the export stamp plus the snapshot identity.
85 pub(crate) fn normalize_recorded_export(markdown: &str) -> String {
86 normalize_snapshot_identity(&normalize_export_time(markdown))
87 }
88
89 /// Assert that an envelope built from exactly `SESSION_EXPORT` exposes the
90 /// export facet and no other facet.
91 ///
92 /// The exhaustive destructuring is deliberate. `ContextParts` is not
93 /// `#[non_exhaustive]`, so adding a facet fails to compile here until the new
94 /// slot is classified, and every slot is asserted absent in one place. That
95 /// turns the "exposure of no unrelated facet" acceptance criterion into a
96 /// structural guarantee rather than a spot-check of a chosen few fields.
97 pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) {
98 let ContextParts {
99 session,
100 model,
101 cost,
102 mode_policy,
103 system_prompt,
104 skills,
105 workspace,
106 presentation,
107 media,
108 memory,
109 project,
110 skill_group,
111 plugin,
112 lifecycle,
113 control,
114 export,
115 } = parts;
116
117 assert!(export.is_some(), "the export facet must be exposed");
118
119 let unrelated = [
120 ("session", session.is_some()),
121 ("model", model.is_some()),
122 ("cost", cost.is_some()),
123 ("mode_policy", mode_policy.is_some()),
124 ("system_prompt", system_prompt.is_some()),
125 ("skills", skills.is_some()),
126 ("workspace", workspace.is_some()),
127 ("presentation", presentation.is_some()),
128 ("media", media.is_some()),
129 ("memory", memory.is_some()),
130 ("project", project.is_some()),
131 ("skill_group", skill_group.is_some()),
132 ("plugin", plugin.is_some()),
133 ("lifecycle", lifecycle.is_some()),
134 ("control", control.is_some()),
135 ];
136 for (facet, present) in unrelated {
137 assert!(
138 !present,
139 "{facet} must stay unavailable to a SESSION_EXPORT-only envelope"
140 );
141 }
142 }
143
144 /// The recorded-golden normaliser must replace *only* the wall-clock fields; a
145 /// normaliser that over-matched would turn the golden comparison into a
146 /// tautology, so pin its exact behaviour.
147 #[test]
148 fn recorded_golden_normalisers_replace_only_volatile_fields() {
149 let doc = concat!(
150 "- Exported: 2026-09-11T14:39:58Z\n",
151 "\n",
152 "| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test |\n",
153 "- Restore points: N1 `3d0ad76f0222` (pre-turn turn 3)\n",
154 "| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 |\n",
155 );
156
157 let out = normalize_recorded_export(doc);
158
159 assert!(out.contains("- Exported: <time>\n"), "{out}");
160 assert!(
161 out.contains("| 1 | `<snapshot-id>` | <recorded> | pre-turn:3: Fix the login test |"),
162 "{out}"
163 );
164 assert!(
165 out.contains("- Restore points: N1 `<snapshot-id>` (pre-turn turn 3)"),
166 "{out}"
167 );
168 assert!(
169 out.contains("| 2 | `<snapshot-id>` | <recorded> | tool:call-1 |"),
170 "{out}"
171 );
172 // Nothing volatile survives, and nothing static was touched.
173 assert!(!out.contains("3d0ad76f0222"), "{out}");
174 assert!(!out.contains("6a3bc0698866"), "{out}");
175 assert!(!out.contains("2026-09-11T14:39:58Z"), "{out}");
176 assert!(out.contains("pre-turn:3: Fix the login test"), "{out}");
177 assert!(out.contains("(pre-turn turn 3)"), "{out}");
178 }
179
179 lines RUST