| 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 |