Skip to main content

bcp_decoder/
decoder.rs

1use bcp_types::block::{Block, BlockContent};
2use bcp_types::block_type::BlockType;
3use bcp_types::content_store::ContentStore;
4use bcp_types::summary::Summary;
5use bcp_wire::block_frame::BlockFrame;
6use bcp_wire::header::{HEADER_SIZE, BcpHeader};
7
8use crate::decompression::{self, MAX_BLOCK_DECOMPRESSED_SIZE, MAX_PAYLOAD_DECOMPRESSED_SIZE};
9use crate::error::DecodeError;
10
11/// The result of decoding a BCP payload.
12///
13/// Contains the parsed file header and an ordered sequence of typed
14/// blocks. The END sentinel is consumed during decoding and is not
15/// included in the `blocks` vector.
16///
17/// ```text
18/// ┌──────────────────────────────────────────────────┐
19/// │ DecodedPayload                                   │
20/// │   header: BcpHeader  ← version, flags            │
21/// │   blocks: Vec<Block> ← ordered content blocks    │
22/// └──────────────────────────────────────────────────┘
23/// ```
24pub struct DecodedPayload {
25    /// The parsed file header (magic validated, version checked).
26    pub header: BcpHeader,
27
28    /// Ordered sequence of blocks, excluding the END sentinel.
29    ///
30    /// Block ordering matches the wire order. Annotation blocks
31    /// appear at whatever position the encoder placed them, with
32    /// `target_block_id` referencing earlier blocks by index.
33    pub blocks: Vec<Block>,
34}
35
36/// Synchronous BCP decoder — parses a complete in-memory payload.
37///
38/// The decoder reads an entire BCP payload from a byte slice and
39/// produces a [`DecodedPayload`] containing the header and all typed
40/// blocks. It is the inverse of
41/// `BcpEncoder::encode` from the `bcp-encoder` crate.
42///
43/// Decoding proceeds in four steps:
44///
45///   1. **Header**: Validate and parse the 8-byte file header (magic
46///      number, version, flags, reserved byte).
47///   2. **Whole-payload decompression**: If the header's `COMPRESSED`
48///      flag (bit 0) is set, decompress all bytes after the header
49///      with zstd before parsing block frames.
50///   3. **Block frames**: Iterate block frames by reading `BlockFrame`
51///      envelopes. For each frame:
52///      - If `COMPRESSED` (bit 1): decompress the body with zstd.
53///      - If `IS_REFERENCE` (bit 2): resolve the 32-byte BLAKE3 hash
54///        against the content store to recover the original body.
55///      - Extract the summary sub-block if `HAS_SUMMARY` (bit 0) is set.
56///      - Deserialize the body into the corresponding `BlockContent`.
57///   4. **Termination**: Stop when an END sentinel (type=0xFF) is
58///      encountered. Detect and report trailing data after the sentinel.
59///
60/// Unknown block types are captured as `BlockContent::Unknown` and do
61/// not cause errors — this is the forward compatibility guarantee from
62/// RFC §3, P1 Schema Evolution.
63///
64/// # Example
65///
66/// ```rust
67/// use bcp_encoder::BcpEncoder;
68/// use bcp_decoder::BcpDecoder;
69/// use bcp_types::enums::{Lang, Role};
70///
71/// let payload = BcpEncoder::new()
72///     .add_code(Lang::Rust, "main.rs", b"fn main() {}")
73///     .add_conversation(Role::User, b"hello")
74///     .encode()
75///     .unwrap();
76///
77/// let decoded = BcpDecoder::decode(&payload).unwrap();
78/// assert_eq!(decoded.blocks.len(), 2);
79/// ```
80pub struct BcpDecoder;
81
82impl BcpDecoder {
83    /// Decode a complete BCP payload from a byte slice.
84    ///
85    /// This is the standard entry point for payloads that do not contain
86    /// content-addressed (reference) blocks. If the payload contains
87    /// blocks with the `IS_REFERENCE` flag, use
88    /// [`decode_with_store`](Self::decode_with_store) instead.
89    ///
90    /// Handles whole-payload and per-block zstd decompression
91    /// transparently.
92    ///
93    /// # Errors
94    ///
95    /// - [`DecodeError::InvalidHeader`] if the magic, version, or reserved
96    ///   byte is wrong.
97    /// - [`DecodeError::Wire`] if a block frame is malformed.
98    /// - [`DecodeError::Type`] if a block body fails TLV deserialization.
99    /// - [`DecodeError::DecompressFailed`] if zstd decompression fails.
100    /// - [`DecodeError::DecompressionBomb`] if decompressed size exceeds
101    ///   the safety limit.
102    /// - [`DecodeError::MissingContentStore`] if a reference block is
103    ///   encountered (use `decode_with_store` instead).
104    /// - [`DecodeError::MissingEndSentinel`] if the payload ends without an
105    ///   END block.
106    /// - [`DecodeError::TrailingData`] if extra bytes follow the END
107    ///   sentinel.
108    pub fn decode(payload: &[u8]) -> Result<DecodedPayload, DecodeError> {
109        Self::decode_inner(payload, None)
110    }
111
112    /// Decode a payload that may contain content-addressed blocks.
113    ///
114    /// Same as [`decode`](Self::decode), but accepts a [`ContentStore`]
115    /// for resolving `IS_REFERENCE` blocks. When a block's body is a
116    /// 32-byte BLAKE3 hash, the decoder looks it up in the store to
117    /// retrieve the original body bytes.
118    ///
119    /// # Errors
120    ///
121    /// All errors from [`decode`](Self::decode), plus:
122    /// - [`DecodeError::UnresolvedReference`] if a hash is not found in
123    ///   the content store.
124    pub fn decode_with_store(
125        payload: &[u8],
126        store: &dyn ContentStore,
127    ) -> Result<DecodedPayload, DecodeError> {
128        Self::decode_inner(payload, Some(store))
129    }
130
131    /// Shared decode implementation.
132    fn decode_inner(
133        payload: &[u8],
134        store: Option<&dyn ContentStore>,
135    ) -> Result<DecodedPayload, DecodeError> {
136        // 1. Parse the 8-byte header.
137        let header = BcpHeader::read_from(payload).map_err(DecodeError::InvalidHeader)?;
138
139        // 2. Whole-payload decompression.
140        let block_data: std::borrow::Cow<'_, [u8]> = if header.flags.is_compressed() {
141            let compressed = &payload[HEADER_SIZE..];
142            let decompressed =
143                decompression::decompress(compressed, MAX_PAYLOAD_DECOMPRESSED_SIZE)?;
144            std::borrow::Cow::Owned(decompressed)
145        } else {
146            std::borrow::Cow::Borrowed(&payload[HEADER_SIZE..])
147        };
148
149        let mut cursor = 0;
150        let mut blocks = Vec::new();
151        let mut found_end = false;
152
153        // 3. Read block frames until END sentinel or EOF.
154        while cursor < block_data.len() {
155            let remaining = &block_data[cursor..];
156
157            if let Some((frame, consumed)) = BlockFrame::read_from(remaining)? {
158                let block = Self::decode_block_frame(&frame, store)?;
159                blocks.push(block);
160                cursor += consumed;
161            } else {
162                // END sentinel encountered. BlockFrame::read_from returns
163                // None for type=0xFF. Account for the END frame bytes:
164                // varint(0xFF) = [0xFF, 0x01] + flags(0x00) + content_len(0x00) = 4 bytes.
165                // But we need to calculate the actual size consumed by the
166                // END sentinel's varint encoding.
167                found_end = true;
168                cursor += Self::end_sentinel_size(remaining)?;
169                break;
170            }
171        }
172
173        // 4. Validate termination.
174        if !found_end {
175            return Err(DecodeError::MissingEndSentinel);
176        }
177
178        if cursor < block_data.len() {
179            return Err(DecodeError::TrailingData {
180                extra_bytes: block_data.len() - cursor,
181            });
182        }
183
184        Ok(DecodedPayload { header, blocks })
185    }
186
187    /// Decode a single block from a `BlockFrame`.
188    ///
189    /// Processing pipeline:
190    ///   1. If `IS_REFERENCE`: resolve the 32-byte hash via content store.
191    ///   2. If `COMPRESSED`: decompress the body with zstd.
192    ///   3. If `HAS_SUMMARY`: extract the summary from the front of the body.
193    ///   4. Deserialize the TLV body into a `BlockContent` variant.
194    fn decode_block_frame(
195        frame: &BlockFrame,
196        store: Option<&dyn ContentStore>,
197    ) -> Result<Block, DecodeError> {
198        let block_type = BlockType::from_wire_id(frame.block_type);
199
200        // Stage 1: Resolve content-addressed references.
201        let resolved_body = if frame.flags.is_reference() {
202            let store = store.ok_or(DecodeError::MissingContentStore)?;
203            if frame.body.len() != 32 {
204                return Err(DecodeError::Wire(bcp_wire::WireError::UnexpectedEof {
205                    offset: frame.body.len(),
206                }));
207            }
208            // Safe: we just checked that body.len() == 32
209            let hash: [u8; 32] = frame.body[..32].try_into().unwrap();
210            store
211                .get(&hash)
212                .ok_or(DecodeError::UnresolvedReference { hash })?
213        } else {
214            frame.body.clone()
215        };
216
217        // Stage 2: Decompress if needed.
218        let decompressed_body = if frame.flags.is_compressed() {
219            decompression::decompress(&resolved_body, MAX_BLOCK_DECOMPRESSED_SIZE)?
220        } else {
221            resolved_body
222        };
223
224        // Stage 3 & 4: Summary extraction + TLV body decode.
225        let mut body = decompressed_body.as_slice();
226        let mut summary = None;
227
228        if frame.flags.has_summary() {
229            let (sum, consumed) = Summary::decode(body)?;
230            summary = Some(sum);
231            body = &body[consumed..];
232        }
233
234        let content = BlockContent::decode_body(&block_type, body)?;
235
236        Ok(Block {
237            block_type,
238            flags: frame.flags,
239            summary,
240            content,
241        })
242    }
243
244    /// Calculate the byte size of the END sentinel in the wire format.
245    ///
246    /// The END sentinel is:
247    ///   - `block_type` = 0xFF, encoded as varint → `[0xFF, 0x01]` (2 bytes)
248    ///   - `flags` = 0x00 (1 byte)
249    ///   - `content_len` = 0, encoded as varint → `[0x00]` (1 byte)
250    ///
251    /// Total: 4 bytes. However, we compute this from the wire rather
252    /// than hardcoding, in case future encoders use a different varint
253    /// encoding width.
254    fn end_sentinel_size(buf: &[u8]) -> Result<usize, DecodeError> {
255        // Read the block_type varint (0xFF → encodes as [0xFF, 0x01])
256        let (_, type_len) = bcp_wire::varint::decode_varint(buf)?;
257        let mut size = type_len;
258
259        // flags byte
260        if size >= buf.len() {
261            return Err(DecodeError::Wire(bcp_wire::WireError::UnexpectedEof {
262                offset: size,
263            }));
264        }
265        size += 1;
266
267        // content_len varint (should be 0)
268        let rest = buf.get(size..).ok_or(DecodeError::Wire(
269            bcp_wire::WireError::UnexpectedEof { offset: size },
270        ))?;
271        if rest.is_empty() {
272            return Err(DecodeError::Wire(bcp_wire::WireError::UnexpectedEof {
273                offset: size,
274            }));
275        }
276        let (_, len_size) = bcp_wire::varint::decode_varint(rest)?;
277        size += len_size;
278
279        Ok(size)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use bcp_encoder::BcpEncoder;
287    use bcp_types::diff::DiffHunk;
288    use bcp_types::enums::{
289        AnnotationKind, DataFormat, FormatHint, Lang, MediaType, Priority, Role, Status,
290    };
291    use bcp_types::file_tree::{FileEntry, FileEntryKind};
292    use bcp_wire::block_frame::{BlockFlags, BlockFrame};
293
294    // ── Round-trip helpers ────────────────────────────────────────────────
295
296    /// Encode with `BcpEncoder`, decode with `BcpDecoder`, return blocks.
297    fn roundtrip(encoder: &BcpEncoder) -> DecodedPayload {
298        let payload = encoder.encode().unwrap();
299        BcpDecoder::decode(&payload).unwrap()
300    }
301
302    // ── Acceptance criteria tests ─────────────────────────────────────────
303
304    #[test]
305    fn decode_parses_encoder_output() {
306        let payload = BcpEncoder::new()
307            .add_code(Lang::Rust, "main.rs", b"fn main() {}")
308            .encode()
309            .unwrap();
310
311        let decoded = BcpDecoder::decode(&payload).unwrap();
312        assert_eq!(decoded.blocks.len(), 1);
313        assert_eq!(decoded.header.version_major, 1);
314        assert_eq!(decoded.header.version_minor, 0);
315    }
316
317    #[test]
318    fn roundtrip_single_code_block() {
319        let decoded =
320            roundtrip(BcpEncoder::new().add_code(Lang::Rust, "lib.rs", b"pub fn hello() {}"));
321
322        assert_eq!(decoded.blocks.len(), 1);
323        let block = &decoded.blocks[0];
324        assert_eq!(block.block_type, BlockType::Code);
325        assert!(block.summary.is_none());
326
327        match &block.content {
328            BlockContent::Code(code) => {
329                assert_eq!(code.lang, Lang::Rust);
330                assert_eq!(code.path, "lib.rs");
331                assert_eq!(code.content, b"pub fn hello() {}");
332                assert!(code.line_range.is_none());
333            }
334            other => panic!("expected Code, got {other:?}"),
335        }
336    }
337
338    #[test]
339    fn roundtrip_multiple_block_types() {
340        let decoded = roundtrip(
341            BcpEncoder::new()
342                .add_code(Lang::Python, "app.py", b"print('hi')")
343                .add_conversation(Role::User, b"What is this?")
344                .add_conversation(Role::Assistant, b"A greeting script.")
345                .add_tool_result("pytest", Status::Ok, b"1 passed")
346                .add_document("README", b"# Hello", FormatHint::Markdown),
347        );
348
349        assert_eq!(decoded.blocks.len(), 5);
350
351        // Verify type ordering matches encoder order
352        let types: Vec<_> = decoded
353            .blocks
354            .iter()
355            .map(|b| b.block_type.clone())
356            .collect();
357        assert_eq!(
358            types,
359            vec![
360                BlockType::Code,
361                BlockType::Conversation,
362                BlockType::Conversation,
363                BlockType::ToolResult,
364                BlockType::Document,
365            ]
366        );
367    }
368
369    #[test]
370    fn roundtrip_with_summary() {
371        let decoded = roundtrip(
372            BcpEncoder::new()
373                .add_code(Lang::Rust, "main.rs", b"fn main() {}")
374                .with_summary("Application entry point.").unwrap(),
375        );
376
377        assert_eq!(decoded.blocks.len(), 1);
378        let block = &decoded.blocks[0];
379        assert!(block.flags.has_summary());
380        assert_eq!(
381            block.summary.as_ref().unwrap().text,
382            "Application entry point."
383        );
384
385        // The content should still decode correctly
386        match &block.content {
387            BlockContent::Code(code) => {
388                assert_eq!(code.path, "main.rs");
389            }
390            other => panic!("expected Code, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn roundtrip_with_priority_annotation() {
396        let decoded = roundtrip(
397            BcpEncoder::new()
398                .add_code(Lang::Rust, "lib.rs", b"// code")
399                .with_priority(Priority::High).unwrap(),
400        );
401
402        // Encoder produces CODE + ANNOTATION blocks
403        assert_eq!(decoded.blocks.len(), 2);
404        assert_eq!(decoded.blocks[0].block_type, BlockType::Code);
405        assert_eq!(decoded.blocks[1].block_type, BlockType::Annotation);
406
407        match &decoded.blocks[1].content {
408            BlockContent::Annotation(ann) => {
409                assert_eq!(ann.target_block_id, 0);
410                assert_eq!(ann.kind, AnnotationKind::Priority);
411                assert_eq!(ann.value, vec![Priority::High.to_wire_byte()]);
412            }
413            other => panic!("expected Annotation, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn roundtrip_all_block_types() {
419        let decoded = roundtrip(
420            BcpEncoder::new()
421                .add_code(Lang::Rust, "main.rs", b"fn main() {}")
422                .add_conversation(Role::User, b"hello")
423                .add_file_tree(
424                    "/project",
425                    vec![FileEntry {
426                        name: "lib.rs".to_string(),
427                        kind: FileEntryKind::File,
428                        size: 100,
429                        children: vec![],
430                    }],
431                )
432                .add_tool_result("rg", Status::Ok, b"3 matches")
433                .add_document("README", b"# Title", FormatHint::Markdown)
434                .add_structured_data(DataFormat::Json, b"{\"key\": \"val\"}")
435                .add_diff(
436                    "src/lib.rs",
437                    vec![DiffHunk {
438                        old_start: 1,
439                        new_start: 1,
440                        lines: b"+new line\n".to_vec(),
441                    }],
442                )
443                .add_annotation(0, AnnotationKind::Tag, b"important")
444                .add_image(MediaType::Png, "screenshot", b"\x89PNG")
445                .add_extension("myco", "custom", b"data"),
446        );
447
448        assert_eq!(decoded.blocks.len(), 10);
449        let types: Vec<_> = decoded
450            .blocks
451            .iter()
452            .map(|b| b.block_type.clone())
453            .collect();
454        assert_eq!(
455            types,
456            vec![
457                BlockType::Code,
458                BlockType::Conversation,
459                BlockType::FileTree,
460                BlockType::ToolResult,
461                BlockType::Document,
462                BlockType::StructuredData,
463                BlockType::Diff,
464                BlockType::Annotation,
465                BlockType::Image,
466                BlockType::Extension,
467            ]
468        );
469    }
470
471    #[test]
472    fn roundtrip_code_with_line_range() {
473        let decoded = roundtrip(BcpEncoder::new().add_code_range(
474            Lang::Rust,
475            "lib.rs",
476            b"fn foo() {}",
477            10,
478            20,
479        ));
480
481        match &decoded.blocks[0].content {
482            BlockContent::Code(code) => {
483                assert_eq!(code.line_range, Some((10, 20)));
484            }
485            other => panic!("expected Code, got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn roundtrip_conversation_with_tool_call_id() {
491        let decoded =
492            roundtrip(BcpEncoder::new().add_conversation_tool(Role::Tool, b"result", "call_abc"));
493
494        match &decoded.blocks[0].content {
495            BlockContent::Conversation(conv) => {
496                assert_eq!(conv.tool_call_id.as_deref(), Some("call_abc"));
497            }
498            other => panic!("expected Conversation, got {other:?}"),
499        }
500    }
501
502    #[test]
503    fn roundtrip_preserves_all_field_values() {
504        // Comprehensive field-level round-trip for complex blocks.
505        let decoded = roundtrip(
506            BcpEncoder::new()
507                .add_file_tree(
508                    "/project/src",
509                    vec![
510                        FileEntry {
511                            name: "main.rs".to_string(),
512                            kind: FileEntryKind::File,
513                            size: 512,
514                            children: vec![],
515                        },
516                        FileEntry {
517                            name: "lib".to_string(),
518                            kind: FileEntryKind::Directory,
519                            size: 0,
520                            children: vec![FileEntry {
521                                name: "utils.rs".to_string(),
522                                kind: FileEntryKind::File,
523                                size: 128,
524                                children: vec![],
525                            }],
526                        },
527                    ],
528                )
529                .add_diff(
530                    "Cargo.toml",
531                    vec![
532                        DiffHunk {
533                            old_start: 5,
534                            new_start: 5,
535                            lines: b"+tokio = \"1\"\n".to_vec(),
536                        },
537                        DiffHunk {
538                            old_start: 20,
539                            new_start: 21,
540                            lines: b"-old_dep = \"0.1\"\n+new_dep = \"0.2\"\n".to_vec(),
541                        },
542                    ],
543                ),
544        );
545
546        assert_eq!(decoded.blocks.len(), 2);
547
548        // Verify FileTree fields
549        match &decoded.blocks[0].content {
550            BlockContent::FileTree(tree) => {
551                assert_eq!(tree.root_path, "/project/src");
552                assert_eq!(tree.entries.len(), 2);
553                assert_eq!(tree.entries[0].name, "main.rs");
554                assert_eq!(tree.entries[0].size, 512);
555                assert_eq!(tree.entries[1].name, "lib");
556                assert_eq!(tree.entries[1].children.len(), 1);
557                assert_eq!(tree.entries[1].children[0].name, "utils.rs");
558            }
559            other => panic!("expected FileTree, got {other:?}"),
560        }
561
562        // Verify Diff fields
563        match &decoded.blocks[1].content {
564            BlockContent::Diff(diff) => {
565                assert_eq!(diff.path, "Cargo.toml");
566                assert_eq!(diff.hunks.len(), 2);
567                assert_eq!(diff.hunks[0].old_start, 5);
568                assert_eq!(diff.hunks[1].old_start, 20);
569                assert_eq!(diff.hunks[1].new_start, 21);
570            }
571            other => panic!("expected Diff, got {other:?}"),
572        }
573    }
574
575    // ── Validation tests ──────────────────────────────────────────────────
576
577    #[test]
578    fn rejects_bad_magic() {
579        let mut payload = BcpEncoder::new()
580            .add_conversation(Role::User, b"hi")
581            .encode()
582            .unwrap();
583
584        // Corrupt the magic bytes
585        payload[0] = b'X';
586        let result = BcpDecoder::decode(&payload);
587        assert!(matches!(result, Err(DecodeError::InvalidHeader(_))));
588    }
589
590    #[test]
591    fn rejects_truncated_header() {
592        let result = BcpDecoder::decode(&[0x4C, 0x43, 0x50, 0x00]);
593        assert!(matches!(result, Err(DecodeError::InvalidHeader(_))));
594    }
595
596    #[test]
597    fn rejects_missing_end_sentinel() {
598        let payload = BcpEncoder::new()
599            .add_conversation(Role::User, b"hi")
600            .encode()
601            .unwrap();
602
603        // Strip the last 4 bytes (the END sentinel)
604        let truncated = &payload[..payload.len() - 4];
605        let result = BcpDecoder::decode(truncated);
606        assert!(matches!(result, Err(DecodeError::MissingEndSentinel)));
607    }
608
609    #[test]
610    fn detects_trailing_data() {
611        let mut payload = BcpEncoder::new()
612            .add_conversation(Role::User, b"hi")
613            .encode()
614            .unwrap();
615
616        // Append garbage after the END sentinel
617        payload.extend_from_slice(b"trailing garbage");
618        let result = BcpDecoder::decode(&payload);
619        assert!(matches!(
620            result,
621            Err(DecodeError::TrailingData { extra_bytes: 16 })
622        ));
623    }
624
625    #[test]
626    fn unknown_block_type_captured_not_rejected() {
627        // Manually construct a payload with an unknown block type (0x42).
628        // We'll build: header + unknown frame + END sentinel.
629        use bcp_wire::header::HeaderFlags;
630
631        let mut payload = vec![0u8; HEADER_SIZE];
632        let header = BcpHeader::new(HeaderFlags::NONE);
633        header.write_to(&mut payload).unwrap();
634
635        // Unknown block frame: type=0x42, flags=0x00, content_len=5, body=b"hello"
636        let frame = BlockFrame {
637            block_type: 0x42,
638            flags: BlockFlags::NONE,
639            body: b"hello".to_vec(),
640        };
641        frame.write_to(&mut payload).unwrap();
642
643        // END sentinel
644        let end = BlockFrame {
645            block_type: 0xFF,
646            flags: BlockFlags::NONE,
647            body: Vec::new(),
648        };
649        end.write_to(&mut payload).unwrap();
650
651        let decoded = BcpDecoder::decode(&payload).unwrap();
652        assert_eq!(decoded.blocks.len(), 1);
653        assert_eq!(decoded.blocks[0].block_type, BlockType::Unknown(0x42));
654
655        match &decoded.blocks[0].content {
656            BlockContent::Unknown { type_id, body } => {
657                assert_eq!(*type_id, 0x42);
658                assert_eq!(body, b"hello");
659            }
660            other => panic!("expected Unknown, got {other:?}"),
661        }
662    }
663
664    #[test]
665    fn optional_fields_absent_result_in_none() {
666        let decoded = roundtrip(
667            BcpEncoder::new()
668                .add_code(Lang::Rust, "x.rs", b"let x = 1;")
669                .add_conversation(Role::User, b"msg"),
670        );
671
672        // Code: line_range should be None
673        match &decoded.blocks[0].content {
674            BlockContent::Code(code) => assert!(code.line_range.is_none()),
675            other => panic!("expected Code, got {other:?}"),
676        }
677
678        // Conversation: tool_call_id should be None
679        match &decoded.blocks[1].content {
680            BlockContent::Conversation(conv) => assert!(conv.tool_call_id.is_none()),
681            other => panic!("expected Conversation, got {other:?}"),
682        }
683    }
684
685    #[test]
686    fn summary_extraction_with_body() {
687        let decoded = roundtrip(
688            BcpEncoder::new()
689                .add_document(
690                    "Guide",
691                    b"# Getting Started\n\nWelcome!",
692                    FormatHint::Markdown,
693                )
694                .with_summary("Onboarding guide for new contributors.").unwrap(),
695        );
696
697        let block = &decoded.blocks[0];
698        assert!(block.flags.has_summary());
699        assert_eq!(
700            block.summary.as_ref().unwrap().text,
701            "Onboarding guide for new contributors."
702        );
703
704        match &block.content {
705            BlockContent::Document(doc) => {
706                assert_eq!(doc.title, "Guide");
707                assert_eq!(doc.content, b"# Getting Started\n\nWelcome!");
708                assert_eq!(doc.format_hint, FormatHint::Markdown);
709            }
710            other => panic!("expected Document, got {other:?}"),
711        }
712    }
713
714    #[test]
715    fn rfc_example_roundtrip() {
716        let decoded = roundtrip(
717            BcpEncoder::new()
718                .add_code(Lang::Rust, "src/main.rs", b"fn main() { todo!() }")
719                .with_summary("Entry point: CLI setup and server startup.").unwrap()
720                .with_priority(Priority::High).unwrap()
721                .add_conversation(Role::User, b"Fix the timeout bug.")
722                .add_conversation(Role::Assistant, b"I'll examine the pool config...")
723                .add_tool_result("ripgrep", Status::Ok, b"3 matches found."),
724        );
725
726        assert_eq!(decoded.blocks.len(), 5);
727
728        // Block 0: CODE with summary
729        assert_eq!(decoded.blocks[0].block_type, BlockType::Code);
730        assert_eq!(
731            decoded.blocks[0].summary.as_ref().unwrap().text,
732            "Entry point: CLI setup and server startup."
733        );
734
735        // Block 1: ANNOTATION (priority)
736        assert_eq!(decoded.blocks[1].block_type, BlockType::Annotation);
737
738        // Block 2-3: CONVERSATION
739        assert_eq!(decoded.blocks[2].block_type, BlockType::Conversation);
740        assert_eq!(decoded.blocks[3].block_type, BlockType::Conversation);
741
742        // Block 4: TOOL_RESULT
743        assert_eq!(decoded.blocks[4].block_type, BlockType::ToolResult);
744    }
745
746    #[test]
747    fn empty_body_blocks() {
748        // Extension with empty content
749        let decoded = roundtrip(BcpEncoder::new().add_extension("ns", "type", b""));
750
751        match &decoded.blocks[0].content {
752            BlockContent::Extension(ext) => {
753                assert_eq!(ext.namespace, "ns");
754                assert_eq!(ext.type_name, "type");
755                assert!(ext.content.is_empty());
756            }
757            other => panic!("expected Extension, got {other:?}"),
758        }
759    }
760
761    // ── Per-block compression roundtrip tests ───────────────────────────
762
763    #[test]
764    fn roundtrip_per_block_compression() {
765        let big_content = "fn main() { println!(\"hello world\"); }\n".repeat(50);
766        let payload = BcpEncoder::new()
767            .add_code(Lang::Rust, "main.rs", big_content.as_bytes())
768            .with_compression().unwrap()
769            .encode()
770            .unwrap();
771
772        // Verify the block is actually compressed on the wire
773        let frame_buf = &payload[HEADER_SIZE..];
774        let (frame, _) = BlockFrame::read_from(frame_buf).unwrap().unwrap();
775        assert!(frame.flags.is_compressed());
776
777        // Decode should transparently decompress
778        let decoded = BcpDecoder::decode(&payload).unwrap();
779        assert_eq!(decoded.blocks.len(), 1);
780        match &decoded.blocks[0].content {
781            BlockContent::Code(code) => {
782                assert_eq!(code.path, "main.rs");
783                assert_eq!(code.content, big_content.as_bytes());
784            }
785            other => panic!("expected Code, got {other:?}"),
786        }
787    }
788
789    #[test]
790    fn roundtrip_per_block_compression_with_summary() {
791        let big_content = "pub fn process() -> Result<(), Error> { Ok(()) }\n".repeat(50);
792        let payload = BcpEncoder::new()
793            .add_code(Lang::Rust, "lib.rs", big_content.as_bytes())
794            .with_summary("Main processing function.").unwrap()
795            .with_compression().unwrap()
796            .encode()
797            .unwrap();
798
799        let decoded = BcpDecoder::decode(&payload).unwrap();
800        let block = &decoded.blocks[0];
801        assert!(block.flags.has_summary());
802        assert!(block.flags.is_compressed());
803        assert_eq!(
804            block.summary.as_ref().unwrap().text,
805            "Main processing function."
806        );
807        match &block.content {
808            BlockContent::Code(code) => assert_eq!(code.content, big_content.as_bytes()),
809            other => panic!("expected Code, got {other:?}"),
810        }
811    }
812
813    // ── Whole-payload compression roundtrip tests ───────────────────────
814
815    #[test]
816    fn roundtrip_whole_payload_compression() {
817        let big_content = "use std::io;\n".repeat(100);
818        let payload = BcpEncoder::new()
819            .add_code(Lang::Rust, "a.rs", big_content.as_bytes())
820            .add_code(Lang::Rust, "b.rs", big_content.as_bytes())
821            .compress_payload()
822            .encode()
823            .unwrap();
824
825        let decoded = BcpDecoder::decode(&payload).unwrap();
826        assert_eq!(decoded.blocks.len(), 2);
827        assert!(decoded.header.flags.is_compressed());
828
829        for block in &decoded.blocks {
830            match &block.content {
831                BlockContent::Code(code) => {
832                    assert_eq!(code.content, big_content.as_bytes());
833                }
834                other => panic!("expected Code, got {other:?}"),
835            }
836        }
837    }
838
839    // ── Content addressing roundtrip tests ──────────────────────────────
840
841    #[test]
842    fn roundtrip_content_addressing() {
843        use bcp_encoder::MemoryContentStore;
844        use std::sync::Arc;
845
846        let store = Arc::new(MemoryContentStore::new());
847        let payload = BcpEncoder::new()
848            .set_content_store(store.clone())
849            .add_code(Lang::Rust, "main.rs", b"fn main() {}")
850            .with_content_addressing().unwrap()
851            .encode()
852            .unwrap();
853
854        // Verify it's a reference on the wire
855        let frame_buf = &payload[HEADER_SIZE..];
856        let (frame, _) = BlockFrame::read_from(frame_buf).unwrap().unwrap();
857        assert!(frame.flags.is_reference());
858        assert_eq!(frame.body.len(), 32);
859
860        // decode() without store should fail
861        let result = BcpDecoder::decode(&payload);
862        assert!(matches!(result, Err(DecodeError::MissingContentStore)));
863
864        // decode_with_store should succeed
865        let decoded = BcpDecoder::decode_with_store(&payload, store.as_ref()).unwrap();
866        assert_eq!(decoded.blocks.len(), 1);
867        match &decoded.blocks[0].content {
868            BlockContent::Code(code) => {
869                assert_eq!(code.path, "main.rs");
870                assert_eq!(code.content, b"fn main() {}");
871            }
872            other => panic!("expected Code, got {other:?}"),
873        }
874    }
875
876    #[test]
877    fn roundtrip_auto_dedup() {
878        use bcp_encoder::MemoryContentStore;
879        use std::sync::Arc;
880
881        let store = Arc::new(MemoryContentStore::new());
882        let payload = BcpEncoder::new()
883            .set_content_store(store.clone())
884            .auto_dedup()
885            .add_code(Lang::Rust, "main.rs", b"fn main() {}")
886            .add_code(Lang::Rust, "main.rs", b"fn main() {}") // duplicate
887            .encode()
888            .unwrap();
889
890        let decoded = BcpDecoder::decode_with_store(&payload, store.as_ref()).unwrap();
891        assert_eq!(decoded.blocks.len(), 2);
892
893        // Both should decode to the same content
894        for block in &decoded.blocks {
895            match &block.content {
896                BlockContent::Code(code) => {
897                    assert_eq!(code.content, b"fn main() {}");
898                }
899                other => panic!("expected Code, got {other:?}"),
900            }
901        }
902    }
903
904    #[test]
905    fn unresolved_reference_errors() {
906        use bcp_encoder::MemoryContentStore;
907        use std::sync::Arc;
908
909        let encode_store = Arc::new(MemoryContentStore::new());
910        let payload = BcpEncoder::new()
911            .set_content_store(encode_store)
912            .add_code(Lang::Rust, "main.rs", b"fn main() {}")
913            .with_content_addressing().unwrap()
914            .encode()
915            .unwrap();
916
917        // Decode with a fresh (empty) store — hash won't be found
918        let decode_store = MemoryContentStore::new();
919        let result = BcpDecoder::decode_with_store(&payload, &decode_store);
920        assert!(matches!(
921            result,
922            Err(DecodeError::UnresolvedReference { .. })
923        ));
924    }
925
926    // ── Combined compression + content addressing ───────────────────────
927
928    #[test]
929    fn roundtrip_refs_with_whole_payload_compression() {
930        use bcp_encoder::MemoryContentStore;
931        use std::sync::Arc;
932
933        let store = Arc::new(MemoryContentStore::new());
934        let big_content = "fn process() -> bool { true }\n".repeat(50);
935        let payload = BcpEncoder::new()
936            .set_content_store(store.clone())
937            .compress_payload()
938            .add_code(Lang::Rust, "main.rs", big_content.as_bytes())
939            .with_content_addressing().unwrap()
940            .add_conversation(Role::User, b"Review this code")
941            .encode()
942            .unwrap();
943
944        let decoded = BcpDecoder::decode_with_store(&payload, store.as_ref()).unwrap();
945        assert_eq!(decoded.blocks.len(), 2);
946
947        match &decoded.blocks[0].content {
948            BlockContent::Code(code) => {
949                assert_eq!(code.content, big_content.as_bytes());
950            }
951            other => panic!("expected Code, got {other:?}"),
952        }
953        match &decoded.blocks[1].content {
954            BlockContent::Conversation(conv) => {
955                assert_eq!(conv.content, b"Review this code");
956            }
957            other => panic!("expected Conversation, got {other:?}"),
958        }
959    }
960}