Skip to main content

faf_fafb/
compile.rs

1//! Compile/decompile API for .faf ↔ .fafb conversion — FAFb v2, closed canonical.
2//!
3//! The writer emits exactly the canonical chunk set (see `canon`), in canonical
4//! order. Non-canonical top-level YAML keys are folded into the `context`
5//! chunk — nothing is lost, and the string table stays fixed. Content ID is
6//! computed over stored payloads in this order; stamps do not enter it.
7
8use std::io::Write;
9
10use super::canon::{CANONICAL_CHUNKS, CanonicalChunk, ChunkClassification, canonical_chunk};
11use super::error::{FafbError, FafbResult};
12use super::header::{FafbHeader, HEADER_SIZE, MAX_FILE_SIZE, MAX_SECTIONS};
13use super::priority::Priority;
14use super::section::{SECTION_ENTRY_SIZE, SectionEntry, SectionTable};
15use super::string_table::StringTable;
16
17/// Options for compilation
18#[derive(Debug, Clone)]
19pub struct CompileOptions {
20    /// Whether to include a timestamp (set to false for deterministic output in tests)
21    pub use_timestamp: bool,
22}
23
24impl Default for CompileOptions {
25    fn default() -> Self {
26        Self {
27            use_timestamp: true,
28        }
29    }
30}
31
32/// A decompiled .fafb file with header, section table, string table, and raw data
33#[derive(Debug, Clone)]
34pub struct DecompiledFafb {
35    /// The 32-byte header
36    pub header: FafbHeader,
37    /// Section table with all entries
38    pub section_table: SectionTable,
39    /// Raw file data (for extracting section content)
40    pub data: Vec<u8>,
41    /// String table — maps section_name_index to name strings
42    string_table: StringTable,
43}
44
45impl DecompiledFafb {
46    /// Extract the raw bytes for a section entry
47    pub fn section_data(&self, entry: &SectionEntry) -> Option<&[u8]> {
48        let start = entry.offset as usize;
49        let end = start + entry.length as usize;
50        if end <= self.data.len() {
51            Some(&self.data[start..end])
52        } else {
53            None
54        }
55    }
56
57    /// Extract section data as a UTF-8 string
58    pub fn section_string(&self, entry: &SectionEntry) -> Option<String> {
59        self.section_data(entry)
60            .and_then(|bytes| std::str::from_utf8(bytes).ok())
61            .map(|s| s.to_string())
62    }
63
64    /// Get the string table
65    pub fn string_table(&self) -> &StringTable {
66        &self.string_table
67    }
68
69    /// Get section name by entry — looks up in string table.
70    /// Unknown indices read as "UNKNOWN" (the IFF rule: skip, don't fail).
71    pub fn section_name(&self, entry: &SectionEntry) -> String {
72        self.string_table
73            .get(entry.name_index)
74            .unwrap_or("UNKNOWN")
75            .to_string()
76    }
77
78    /// Get section data by name
79    pub fn get_section_by_name(&self, name: &str) -> Option<&[u8]> {
80        let idx = self.string_table.index_of(name)?;
81        self.section_table
82            .entries()
83            .iter()
84            .find(|e| e.name_index == idx)
85            .and_then(|entry| self.section_data(entry))
86    }
87
88    /// Get section data by name as string
89    pub fn get_section_string_by_name(&self, name: &str) -> Option<String> {
90        self.get_section_by_name(name)
91            .and_then(|bytes| std::str::from_utf8(bytes).ok())
92            .map(|s| s.to_string())
93    }
94
95    /// Get all DNA sections
96    pub fn dna_sections(&self) -> Vec<&SectionEntry> {
97        self.section_table
98            .entries()
99            .iter()
100            .filter(|e| e.classification() == ChunkClassification::Dna)
101            .collect()
102    }
103
104    /// Get all Context sections
105    pub fn context_sections(&self) -> Vec<&SectionEntry> {
106        self.section_table
107            .entries()
108            .iter()
109            .filter(|e| e.classification() == ChunkClassification::Context)
110            .collect()
111    }
112
113    /// Get the Pointer section (docs)
114    pub fn pointer_section(&self) -> Option<&SectionEntry> {
115        self.section_table
116            .entries()
117            .iter()
118            .find(|e| e.classification() == ChunkClassification::Pointer)
119    }
120
121    /// Content ID of this file (see [`crate::content_id_of`]).
122    pub fn content_id(&self) -> String {
123        super::identity::content_id_of(self)
124    }
125
126    /// Canonical text rendering of content chunks.
127    pub fn canonical_rendering(&self) -> String {
128        super::identity::canonical_rendering(self)
129    }
130
131    /// Truncated rendering after dropping whole tiers from the tail.
132    pub fn truncated_rendering(&self, drop_up_to: super::identity::TruncationTier) -> String {
133        super::identity::truncated_rendering(self, drop_up_to)
134    }
135}
136
137/// Compile a .faf YAML source string into .fafb v2 binary bytes.
138///
139/// FAFb v2 is closed canonical: only the canonical chunk set produces
140/// sections, serialized in canonical order. Non-canonical top-level keys are
141/// folded into the `context` chunk (sorted alphabetically, after any authored
142/// `context` content) — preserved, never named.
143///
144/// # Example
145///
146/// ```rust
147/// use faf_fafb::{compile, CompileOptions};
148///
149/// let yaml = r#"
150/// faf_version: 2.5.0
151/// project:
152///   name: my-project
153///   goal: Build something great
154/// custom_data:
155///   key: value
156/// "#;
157///
158/// let opts = CompileOptions { use_timestamp: false };
159/// let fafb_bytes = compile(yaml, &opts).unwrap();
160/// assert_eq!(&fafb_bytes[0..4], b"FAFB");
161/// assert_eq!(fafb_bytes[4], 2); // FAFb v2
162/// ```
163pub fn compile(yaml_source: &str, options: &CompileOptions) -> Result<Vec<u8>, String> {
164    let source_bytes = yaml_source.as_bytes();
165    if source_bytes.is_empty() {
166        return Err("Source content is empty".to_string());
167    }
168
169    let yaml: serde_yaml_ng::Value =
170        serde_yaml_ng::from_str(yaml_source).map_err(|e| format!("Invalid YAML: {}", e))?;
171
172    let mapping = yaml
173        .as_mapping()
174        .ok_or_else(|| "YAML root must be a mapping".to_string())?;
175
176    // Partition top-level keys: canonical chunks vs keys to fold into context.
177    let mut canonical_values: Vec<(&'static CanonicalChunk, serde_yaml_ng::Value)> = Vec::new();
178    let mut folded: Vec<(String, serde_yaml_ng::Value)> = Vec::new();
179
180    for (key, value) in mapping {
181        let key_str = key
182            .as_str()
183            .ok_or_else(|| "YAML key must be a string".to_string())?;
184        if key_str.starts_with("__") {
185            return Err(format!(
186                "Unknown structural name '{}': writers emit only the closed structural list",
187                key_str
188            ));
189        }
190        match canonical_chunk(key_str) {
191            Some(chunk) => canonical_values.push((chunk, value.clone())),
192            None => folded.push((key_str.to_string(), value.clone())),
193        }
194    }
195
196    // Fold non-canonical keys into the context chunk: authored context entries
197    // first, folded keys after, sorted alphabetically.
198    if !folded.is_empty() {
199        folded.sort_by(|a, b| a.0.cmp(&b.0));
200
201        let context_chunk = canonical_chunk("context").expect("context is canonical");
202        let existing = canonical_values
203            .iter_mut()
204            .find(|(chunk, _)| chunk.name == "context");
205
206        let mut context_map = match existing.as_ref().map(|(_, v)| v) {
207            None => serde_yaml_ng::Mapping::new(),
208            Some(serde_yaml_ng::Value::Mapping(m)) => m.clone(),
209            Some(_) => {
210                return Err("context must be a mapping to fold non-canonical keys into".to_string());
211            }
212        };
213
214        for (key, value) in folded {
215            let yaml_key = serde_yaml_ng::Value::String(key.clone());
216            if context_map.contains_key(&yaml_key) {
217                return Err(format!(
218                    "Cannot fold non-canonical key '{}' into context: key already exists there",
219                    key
220                ));
221            }
222            context_map.insert(yaml_key, value);
223        }
224
225        let merged = serde_yaml_ng::Value::Mapping(context_map);
226        match existing {
227            Some((_, v)) => *v = merged,
228            None => canonical_values.push((context_chunk, merged)),
229        }
230    }
231
232    if canonical_values.is_empty() {
233        return Err("No sections found in YAML".to_string());
234    }
235
236    // Canonical serialization order — same content, same bytes, any key order.
237    canonical_values.sort_by_key(|(chunk, _)| {
238        CANONICAL_CHUNKS
239            .iter()
240            .position(|c| c.name == chunk.name)
241            .expect("chunk is canonical")
242    });
243
244    // Build string table and sections in canonical order.
245    let mut string_table = StringTable::new();
246    let mut sections: Vec<(u8, ChunkClassification, Priority, Vec<u8>)> = Vec::new();
247
248    for (chunk, value) in &canonical_values {
249        let name_idx = string_table
250            .add(chunk.name)
251            .map_err(|e| format!("String table error: {}", e))?;
252
253        let content = serde_yaml_ng::to_string(value)
254            .map_err(|e| format!("Failed to serialize '{}': {}", chunk.name, e))?;
255        let data = format!("{}:\n{}", chunk.name, content).into_bytes();
256
257        sections.push((
258            name_idx,
259            chunk.classification,
260            Priority::new(chunk.priority),
261            data,
262        ));
263    }
264
265    if sections.len() > MAX_SECTIONS as usize {
266        return Err(format!(
267            "Too many sections: {} exceeds maximum {}",
268            sections.len(),
269            MAX_SECTIONS
270        ));
271    }
272
273    // Add __string_table__ name to string table before serializing
274    let st_name_idx = string_table
275        .add("__string_table__")
276        .map_err(|e| format!("String table error: {}", e))?;
277
278    let string_table_bytes = string_table
279        .to_bytes()
280        .map_err(|e| format!("String table serialization error: {}", e))?;
281
282    // Layout: [HEADER 32B] [section data...] [string table data] [section table entries...]
283    let mut data_offset: u32 = HEADER_SIZE as u32;
284    let mut section_data: Vec<u8> = Vec::new();
285    let mut section_table = SectionTable::new();
286
287    for (name_idx, classification, priority, data) in &sections {
288        let entry = SectionEntry::new(*name_idx, data_offset, data.len() as u32)
289            .with_priority(*priority)
290            .with_classification(*classification);
291
292        section_table.push(entry);
293        section_data.extend_from_slice(data);
294        data_offset = data_offset
295            .checked_add(data.len() as u32)
296            .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
297    }
298
299    // String table section (last content section)
300    let st_section_index = section_table.len() as u16;
301    let st_entry = SectionEntry::new(st_name_idx, data_offset, string_table_bytes.len() as u32)
302        .with_priority(Priority::critical());
303
304    section_table.push(st_entry);
305    section_data.extend_from_slice(&string_table_bytes);
306    data_offset = data_offset
307        .checked_add(string_table_bytes.len() as u32)
308        .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
309
310    let section_count = section_table.len();
311    let section_table_size = section_count * SECTION_ENTRY_SIZE;
312    let section_table_offset = data_offset;
313    let total_size = section_table_offset
314        .checked_add(section_table_size as u32)
315        .ok_or_else(|| "Total file size exceeds u32::MAX bytes".to_string())?;
316
317    if total_size > MAX_FILE_SIZE {
318        return Err(format!(
319            "Output size {} bytes exceeds maximum {} bytes (10MB)",
320            total_size, MAX_FILE_SIZE
321        ));
322    }
323
324    // Build header
325    let mut header = if options.use_timestamp {
326        FafbHeader::with_timestamp()
327    } else {
328        FafbHeader::new()
329    };
330    header.set_source_checksum(source_bytes);
331    header.section_count = section_count as u16;
332    header.section_table_offset = section_table_offset;
333    header.total_size = total_size;
334    header.string_table_index = st_section_index;
335
336    // Assemble binary
337    let mut output: Vec<u8> = Vec::with_capacity(total_size as usize);
338    header.write(&mut output).map_err(|e| e.to_string())?;
339    output.write_all(&section_data).map_err(|e| e.to_string())?;
340    section_table
341        .write(&mut output)
342        .map_err(|e| e.to_string())?;
343
344    if output.len() != total_size as usize {
345        return Err(format!(
346            "Internal error: size mismatch (expected {} bytes, got {} bytes)",
347            total_size,
348            output.len()
349        ));
350    }
351
352    Ok(output)
353}
354
355/// Decompile .fafb v2 binary bytes into a structured representation.
356///
357/// Parses header, section table, and string table. FAFb v1 binaries are
358/// rejected (`IncompatibleVersion`) — re-compile from the `.faf` source.
359///
360/// # Example
361///
362/// ```rust
363/// use faf_fafb::{compile, decompile, CompileOptions};
364///
365/// let yaml = "faf_version: 2.5.0\nproject:\n  name: test\n";
366/// let opts = CompileOptions { use_timestamp: false };
367/// let fafb_bytes = compile(yaml, &opts).unwrap();
368///
369/// let result = decompile(&fafb_bytes).unwrap();
370/// assert_eq!(result.header.version_major, 2);
371///
372/// let project = result.get_section_string_by_name("project").unwrap();
373/// assert!(project.contains("test"));
374/// ```
375pub fn decompile(fafb_bytes: &[u8]) -> FafbResult<DecompiledFafb> {
376    let header = FafbHeader::from_bytes(fafb_bytes)?;
377    header.validate(fafb_bytes)?;
378
379    // Read section table
380    let table_start = header.section_table_offset as usize;
381    let table_data = &fafb_bytes[table_start..];
382    let section_table = SectionTable::from_bytes(table_data, header.section_count as usize)?;
383    section_table.validate_bounds(header.total_size)?;
384
385    // Extract string table (required)
386    let st_index = header.string_table_index as usize;
387    if st_index >= section_table.len() {
388        return Err(FafbError::MissingStringTable);
389    }
390    let st_entry = section_table.get(st_index).unwrap();
391    let st_start = st_entry.offset as usize;
392    let st_end = st_start + st_entry.length as usize;
393    if st_end > fafb_bytes.len() {
394        return Err(FafbError::MissingStringTable);
395    }
396    let string_table = StringTable::from_bytes(&fafb_bytes[st_start..st_end])?;
397
398    Ok(DecompiledFafb {
399        header,
400        section_table,
401        data: fafb_bytes.to_vec(),
402        string_table,
403    })
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn opts() -> CompileOptions {
411        CompileOptions {
412            use_timestamp: false,
413        }
414    }
415
416    fn minimal_yaml() -> &'static str {
417        "faf_version: 2.5.0\nproject:\n  name: test-project\n"
418    }
419
420    fn full_yaml() -> &'static str {
421        r#"faf_version: 2.5.0
422project:
423  name: full-project
424  goal: Test the compiler
425tech_stack:
426  languages:
427    - Rust
428    - TypeScript
429commands:
430  build: cargo build
431  test: cargo test
432architecture:
433  style: microservices
434context:
435  notes: some context
436docs:
437  readme: README.md
438custom_field:
439  key: value
440another_custom:
441  deep:
442    nested: data
443"#
444    }
445
446    // ─── Core compile/decompile ───
447
448    #[test]
449    fn test_compile_produces_valid_v2_header() {
450        let bytes = compile(minimal_yaml(), &opts()).unwrap();
451        assert_eq!(&bytes[0..4], b"FAFB");
452        assert_eq!(bytes[4], 2); // FAFb v2
453        assert!(bytes.len() >= HEADER_SIZE);
454    }
455
456    #[test]
457    fn test_compile_empty_fails() {
458        assert!(compile("", &opts()).is_err());
459    }
460
461    #[test]
462    fn test_compile_options_default() {
463        let o = CompileOptions::default();
464        assert!(o.use_timestamp);
465    }
466
467    #[test]
468    fn test_roundtrip_minimal() {
469        let bytes = compile(minimal_yaml(), &opts()).unwrap();
470        let result = decompile(&bytes).unwrap();
471
472        assert_eq!(result.header.version_major, 2);
473        assert!(result.header.flags.has_string_table());
474
475        // faf_version + project + __string_table__
476        assert!(result.section_table.len() >= 3);
477
478        let project = result.get_section_string_by_name("project").unwrap();
479        assert!(project.contains("test-project"));
480    }
481
482    #[test]
483    fn test_v1_binaries_rejected() {
484        let mut bytes = compile(minimal_yaml(), &opts()).unwrap();
485        bytes[4] = 1; // forge a v1 header
486        let err = decompile(&bytes).unwrap_err();
487        assert!(matches!(
488            err,
489            FafbError::IncompatibleVersion { actual: 1, .. }
490        ));
491    }
492
493    #[test]
494    fn test_roundtrip_full() {
495        let bytes = compile(full_yaml(), &opts()).unwrap();
496        let result = decompile(&bytes).unwrap();
497
498        let st = result.string_table();
499        assert!(st.index_of("faf_version").is_some());
500        assert!(st.index_of("project").is_some());
501        assert!(st.index_of("tech_stack").is_some());
502        assert!(st.index_of("commands").is_some());
503
504        // Closed canonical: non-canonical keys never become section names.
505        // `docs` is NOT in faf-cli FafData → it folds, same as custom keys.
506        assert!(st.index_of("docs").is_none());
507        assert!(st.index_of("custom_field").is_none());
508        assert!(st.index_of("another_custom").is_none());
509    }
510
511    #[test]
512    fn test_decompile_invalid_magic() {
513        let bytes = vec![0u8; 32];
514        assert!(decompile(&bytes).is_err());
515    }
516
517    #[test]
518    fn test_decompile_too_small() {
519        let bytes = vec![0u8; 16];
520        assert!(decompile(&bytes).is_err());
521    }
522
523    #[test]
524    fn test_source_checksum() {
525        let yaml = full_yaml();
526        let bytes = compile(yaml, &opts()).unwrap();
527        let result = decompile(&bytes).unwrap();
528
529        let expected = FafbHeader::compute_checksum(yaml.as_bytes());
530        assert_eq!(result.header.source_checksum, expected);
531    }
532
533    #[test]
534    fn test_deterministic_without_timestamp() {
535        let yaml = minimal_yaml();
536        let bytes1 = compile(yaml, &opts()).unwrap();
537        let bytes2 = compile(yaml, &opts()).unwrap();
538        assert_eq!(bytes1, bytes2);
539    }
540
541    #[test]
542    fn test_canonical_order_key_order_independent() {
543        // Same content, shuffled top-level key order → byte-identical output.
544        // (The checksum seals the SOURCE, which differs — compare structure by
545        // zeroing the source_checksum field, bytes 8..12.)
546        let a = "faf_version: 2.5.0\nproject:\n  name: x\ncommands:\n  build: make\n";
547        let b = "commands:\n  build: make\nfaf_version: 2.5.0\nproject:\n  name: x\n";
548
549        let mut bytes_a = compile(a, &opts()).unwrap();
550        let mut bytes_b = compile(b, &opts()).unwrap();
551        for buf in [&mut bytes_a, &mut bytes_b] {
552            for byte in &mut buf[8..12] {
553                *byte = 0;
554            }
555        }
556        assert_eq!(bytes_a, bytes_b);
557    }
558
559    // ─── Folding (closed canonical) ───
560
561    #[test]
562    fn test_non_canonical_keys_folded_into_context() {
563        let yaml =
564            "faf_version: 2.5.0\nproject:\n  name: test\nmy_exotic_field:\n  data: preserved\n";
565        let bytes = compile(yaml, &opts()).unwrap();
566        let result = decompile(&bytes).unwrap();
567
568        // Not a section of its own…
569        assert!(result.string_table().index_of("my_exotic_field").is_none());
570        // …but fully preserved inside context.
571        let context = result.get_section_string_by_name("context").unwrap();
572        assert!(context.contains("my_exotic_field"));
573        assert!(context.contains("preserved"));
574    }
575
576    #[test]
577    fn test_folding_preserves_authored_context() {
578        let bytes = compile(full_yaml(), &opts()).unwrap();
579        let result = decompile(&bytes).unwrap();
580
581        let context = result.get_section_string_by_name("context").unwrap();
582        assert!(context.contains("notes")); // authored content kept
583        assert!(context.contains("custom_field")); // folded
584        assert!(context.contains("another_custom")); // folded
585    }
586
587    #[test]
588    fn test_folding_collision_is_an_error() {
589        let yaml = "project:\n  name: x\ncontext:\n  dupe: authored\ndupe: folded\n";
590        // 'dupe' is non-canonical and collides with context.dupe
591        assert!(compile(yaml, &opts()).is_err());
592    }
593
594    #[test]
595    fn test_folding_into_scalar_context_is_an_error() {
596        let yaml = "project:\n  name: x\ncontext: just a string\nweird_key: value\n";
597        assert!(compile(yaml, &opts()).is_err());
598    }
599
600    #[test]
601    fn test_unknown_dunder_key_is_a_writer_error() {
602        let yaml = "faf_version: 2.5.0\nproject:\n  name: x\n__score__:\n  total: 1\n";
603        let err = compile(yaml, &opts()).unwrap_err();
604        assert!(err.contains("__score__"), "{err}");
605    }
606
607    // ─── Section names ───
608
609    #[test]
610    fn test_section_names() {
611        let bytes = compile(full_yaml(), &opts()).unwrap();
612        let result = decompile(&bytes).unwrap();
613
614        for entry in result.section_table.entries() {
615            let name = result.section_name(entry);
616            assert!(!name.is_empty());
617            assert_ne!(name, "UNKNOWN");
618        }
619    }
620
621    #[test]
622    fn test_get_section_by_name() {
623        let bytes = compile(full_yaml(), &opts()).unwrap();
624        let result = decompile(&bytes).unwrap();
625
626        let project = result.get_section_string_by_name("project");
627        assert!(project.is_some());
628        assert!(project.unwrap().contains("full-project"));
629
630        // `docs` is non-canonical → folded into context, not its own section.
631        assert!(result.get_section_string_by_name("docs").is_none());
632        let context = result.get_section_string_by_name("context").unwrap();
633        assert!(context.contains("docs"));
634        assert!(context.contains("README.md"));
635    }
636
637    // ─── Classification ───
638
639    #[test]
640    fn test_classification_dna() {
641        let bytes = compile(full_yaml(), &opts()).unwrap();
642        let result = decompile(&bytes).unwrap();
643
644        let dna = result.dna_sections();
645        let dna_names: Vec<String> = dna.iter().map(|e| result.section_name(e)).collect();
646
647        assert!(dna_names.contains(&"faf_version".to_string()));
648        assert!(dna_names.contains(&"project".to_string()));
649        assert!(dna_names.contains(&"tech_stack".to_string()));
650        assert!(dna_names.contains(&"commands".to_string()));
651        assert!(dna_names.contains(&"architecture".to_string()));
652
653        // `context` is Context-class (the fold target), not DNA.
654        let ctx_names: Vec<String> = result
655            .context_sections()
656            .iter()
657            .map(|e| result.section_name(e))
658            .collect();
659        assert!(ctx_names.contains(&"context".to_string()));
660    }
661
662    #[test]
663    fn test_no_pointer_section_in_fafdata_truth() {
664        // faf-cli FafData has no `docs` (or any Pointer-class) top-level key,
665        // so a compiled brick has no Pointer section.
666        let bytes = compile(full_yaml(), &opts()).unwrap();
667        let result = decompile(&bytes).unwrap();
668        assert!(result.pointer_section().is_none());
669    }
670
671    // ─── String table ───
672
673    #[test]
674    fn test_string_table_flag_set() {
675        let bytes = compile(minimal_yaml(), &opts()).unwrap();
676        let result = decompile(&bytes).unwrap();
677        assert!(result.header.flags.has_string_table());
678    }
679
680    #[test]
681    fn test_string_table_index_valid() {
682        let bytes = compile(minimal_yaml(), &opts()).unwrap();
683        let result = decompile(&bytes).unwrap();
684
685        let st_idx = result.header.string_table_index as usize;
686        assert!(st_idx < result.section_table.len());
687    }
688
689    // ─── Priority ───
690
691    #[test]
692    fn test_priority_from_canon_table() {
693        let bytes = compile(full_yaml(), &opts()).unwrap();
694        let result = decompile(&bytes).unwrap();
695
696        for entry in result.section_table.entries() {
697            let name = result.section_name(entry);
698            match name.as_str() {
699                "faf_version" | "project" => assert!(entry.priority.is_critical()),
700                "commands" => assert_eq!(entry.priority.value(), 180),
701                "architecture" => assert_eq!(entry.priority.value(), 128),
702                "context" | "scores" => assert_eq!(entry.priority.value(), 64),
703                _ => {}
704            }
705        }
706    }
707
708    // ─── Canonical chunk coverage ───
709
710    #[test]
711    fn test_all_canonical_chunks_compile() {
712        // All 13 faf-cli FafData top-level keys, each becomes a section.
713        let yaml = r#"faf_version: 2.5.0
714project:
715  name: all-types
716app_type: cli
717about:
718  represents: Wolfe-Jam/source
719stack:
720  build: cargo
721human_context:
722  who: devs
723monorepo:
724  packages_count: 3
725tech_stack:
726  - Rust
727key_files:
728  - main.rs
729commands:
730  build: make
731architecture:
732  style: monolith
733scores:
734  total: 100
735context:
736  note: x
737"#;
738        let bytes = compile(yaml, &opts()).unwrap();
739        let result = decompile(&bytes).unwrap();
740
741        let st = result.string_table();
742        for key in &[
743            "faf_version",
744            "project",
745            "app_type",
746            "about",
747            "stack",
748            "human_context",
749            "monorepo",
750            "tech_stack",
751            "key_files",
752            "commands",
753            "architecture",
754            "scores",
755            "context",
756        ] {
757            assert!(
758                st.index_of(key).is_some(),
759                "Expected canonical chunk '{}' in string table",
760                key
761            );
762        }
763    }
764}