1use 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#[derive(Debug, Clone)]
19pub struct CompileOptions {
20 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#[derive(Debug, Clone)]
34pub struct DecompiledFafb {
35 pub header: FafbHeader,
37 pub section_table: SectionTable,
39 pub data: Vec<u8>,
41 string_table: StringTable,
43}
44
45impl DecompiledFafb {
46 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 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 pub fn string_table(&self) -> &StringTable {
66 &self.string_table
67 }
68
69 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 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 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 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 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 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 pub fn content_id(&self) -> String {
123 super::identity::content_id_of(self)
124 }
125
126 pub fn canonical_rendering(&self) -> String {
128 super::identity::canonical_rendering(self)
129 }
130
131 pub fn truncated_rendering(&self, drop_up_to: super::identity::TruncationTier) -> String {
133 super::identity::truncated_rendering(self, drop_up_to)
134 }
135}
136
137pub 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 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 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_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 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 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 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 §ions {
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 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 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 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(§ion_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
355pub fn decompile(fafb_bytes: &[u8]) -> FafbResult<DecompiledFafb> {
376 let header = FafbHeader::from_bytes(fafb_bytes)?;
377 header.validate(fafb_bytes)?;
378
379 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 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 #[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); 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 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; 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 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 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 #[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 assert!(result.string_table().index_of("my_exotic_field").is_none());
570 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")); assert!(context.contains("custom_field")); assert!(context.contains("another_custom")); }
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 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 #[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 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 #[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 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 let bytes = compile(full_yaml(), &opts()).unwrap();
667 let result = decompile(&bytes).unwrap();
668 assert!(result.pointer_section().is_none());
669 }
670
671 #[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 #[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 #[test]
711 fn test_all_canonical_chunks_compile() {
712 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}