1use std::io::Write;
10
11use super::canon::{CANONICAL_CHUNKS, CanonicalChunk, ChunkClassification, canonical_chunk};
12use super::error::{FafbError, FafbResult};
13use super::header::{FafbHeader, HEADER_SIZE, MAX_FILE_SIZE, MAX_SECTIONS};
14use super::priority::Priority;
15use super::section::{SECTION_ENTRY_SIZE, SectionEntry, SectionTable};
16use super::string_table::StringTable;
17
18#[derive(Debug, Clone)]
20pub struct CompileOptions {
21 pub use_timestamp: bool,
23}
24
25impl Default for CompileOptions {
26 fn default() -> Self {
27 Self {
28 use_timestamp: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct DecompiledFafb {
36 pub header: FafbHeader,
38 pub section_table: SectionTable,
40 pub data: Vec<u8>,
42 string_table: StringTable,
44}
45
46impl DecompiledFafb {
47 pub fn section_data(&self, entry: &SectionEntry) -> Option<&[u8]> {
49 let start = entry.offset as usize;
50 let end = start + entry.length as usize;
51 if end <= self.data.len() {
52 Some(&self.data[start..end])
53 } else {
54 None
55 }
56 }
57
58 pub fn section_string(&self, entry: &SectionEntry) -> Option<String> {
60 self.section_data(entry)
61 .and_then(|bytes| std::str::from_utf8(bytes).ok())
62 .map(|s| s.to_string())
63 }
64
65 pub fn string_table(&self) -> &StringTable {
67 &self.string_table
68 }
69
70 pub fn section_name(&self, entry: &SectionEntry) -> String {
73 self.string_table
74 .get(entry.name_index)
75 .unwrap_or("UNKNOWN")
76 .to_string()
77 }
78
79 pub fn get_section_by_name(&self, name: &str) -> Option<&[u8]> {
81 let idx = self.string_table.index_of(name)?;
82 self.section_table
83 .entries()
84 .iter()
85 .find(|e| e.name_index == idx)
86 .and_then(|entry| self.section_data(entry))
87 }
88
89 pub fn get_section_string_by_name(&self, name: &str) -> Option<String> {
91 self.get_section_by_name(name)
92 .and_then(|bytes| std::str::from_utf8(bytes).ok())
93 .map(|s| s.to_string())
94 }
95
96 pub fn dna_sections(&self) -> Vec<&SectionEntry> {
98 self.section_table
99 .entries()
100 .iter()
101 .filter(|e| e.classification() == ChunkClassification::Dna)
102 .collect()
103 }
104
105 pub fn context_sections(&self) -> Vec<&SectionEntry> {
107 self.section_table
108 .entries()
109 .iter()
110 .filter(|e| e.classification() == ChunkClassification::Context)
111 .collect()
112 }
113
114 pub fn pointer_section(&self) -> Option<&SectionEntry> {
116 self.section_table
117 .entries()
118 .iter()
119 .find(|e| e.classification() == ChunkClassification::Pointer)
120 }
121}
122
123pub fn compile(yaml_source: &str, options: &CompileOptions) -> Result<Vec<u8>, String> {
150 let source_bytes = yaml_source.as_bytes();
151 if source_bytes.is_empty() {
152 return Err("Source content is empty".to_string());
153 }
154
155 let yaml: serde_yaml_ng::Value =
156 serde_yaml_ng::from_str(yaml_source).map_err(|e| format!("Invalid YAML: {}", e))?;
157
158 let mapping = yaml
159 .as_mapping()
160 .ok_or_else(|| "YAML root must be a mapping".to_string())?;
161
162 let mut canonical_values: Vec<(&'static CanonicalChunk, serde_yaml_ng::Value)> = Vec::new();
164 let mut folded: Vec<(String, serde_yaml_ng::Value)> = Vec::new();
165
166 for (key, value) in mapping {
167 let key_str = key
168 .as_str()
169 .ok_or_else(|| "YAML key must be a string".to_string())?;
170 match canonical_chunk(key_str) {
171 Some(chunk) => canonical_values.push((chunk, value.clone())),
172 None => folded.push((key_str.to_string(), value.clone())),
173 }
174 }
175
176 if !folded.is_empty() {
179 folded.sort_by(|a, b| a.0.cmp(&b.0));
180
181 let context_chunk = canonical_chunk("context").expect("context is canonical");
182 let existing = canonical_values
183 .iter_mut()
184 .find(|(chunk, _)| chunk.name == "context");
185
186 let mut context_map = match existing.as_ref().map(|(_, v)| v) {
187 None => serde_yaml_ng::Mapping::new(),
188 Some(serde_yaml_ng::Value::Mapping(m)) => m.clone(),
189 Some(_) => {
190 return Err("context must be a mapping to fold non-canonical keys into".to_string());
191 }
192 };
193
194 for (key, value) in folded {
195 let yaml_key = serde_yaml_ng::Value::String(key.clone());
196 if context_map.contains_key(&yaml_key) {
197 return Err(format!(
198 "Cannot fold non-canonical key '{}' into context: key already exists there",
199 key
200 ));
201 }
202 context_map.insert(yaml_key, value);
203 }
204
205 let merged = serde_yaml_ng::Value::Mapping(context_map);
206 match existing {
207 Some((_, v)) => *v = merged,
208 None => canonical_values.push((context_chunk, merged)),
209 }
210 }
211
212 if canonical_values.is_empty() {
213 return Err("No sections found in YAML".to_string());
214 }
215
216 canonical_values.sort_by_key(|(chunk, _)| {
218 CANONICAL_CHUNKS
219 .iter()
220 .position(|c| c.name == chunk.name)
221 .expect("chunk is canonical")
222 });
223
224 let mut string_table = StringTable::new();
226 let mut sections: Vec<(u8, ChunkClassification, Priority, Vec<u8>)> = Vec::new();
227
228 for (chunk, value) in &canonical_values {
229 let name_idx = string_table
230 .add(chunk.name)
231 .map_err(|e| format!("String table error: {}", e))?;
232
233 let content = serde_yaml_ng::to_string(value)
234 .map_err(|e| format!("Failed to serialize '{}': {}", chunk.name, e))?;
235 let data = format!("{}:\n{}", chunk.name, content).into_bytes();
236
237 sections.push((
238 name_idx,
239 chunk.classification,
240 Priority::new(chunk.priority),
241 data,
242 ));
243 }
244
245 if sections.len() > MAX_SECTIONS as usize {
246 return Err(format!(
247 "Too many sections: {} exceeds maximum {}",
248 sections.len(),
249 MAX_SECTIONS
250 ));
251 }
252
253 let st_name_idx = string_table
255 .add("__string_table__")
256 .map_err(|e| format!("String table error: {}", e))?;
257
258 let string_table_bytes = string_table
259 .to_bytes()
260 .map_err(|e| format!("String table serialization error: {}", e))?;
261
262 let mut data_offset: u32 = HEADER_SIZE as u32;
264 let mut section_data: Vec<u8> = Vec::new();
265 let mut section_table = SectionTable::new();
266
267 for (name_idx, classification, priority, data) in §ions {
268 let entry = SectionEntry::new(*name_idx, data_offset, data.len() as u32)
269 .with_priority(*priority)
270 .with_classification(*classification);
271
272 section_table.push(entry);
273 section_data.extend_from_slice(data);
274 data_offset = data_offset
275 .checked_add(data.len() as u32)
276 .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
277 }
278
279 let st_section_index = section_table.len() as u16;
281 let st_entry = SectionEntry::new(st_name_idx, data_offset, string_table_bytes.len() as u32)
282 .with_priority(Priority::critical());
283
284 section_table.push(st_entry);
285 section_data.extend_from_slice(&string_table_bytes);
286 data_offset = data_offset
287 .checked_add(string_table_bytes.len() as u32)
288 .ok_or_else(|| "Section data exceeds u32::MAX bytes".to_string())?;
289
290 let section_count = section_table.len();
291 let section_table_size = section_count * SECTION_ENTRY_SIZE;
292 let section_table_offset = data_offset;
293 let total_size = section_table_offset
294 .checked_add(section_table_size as u32)
295 .ok_or_else(|| "Total file size exceeds u32::MAX bytes".to_string())?;
296
297 if total_size > MAX_FILE_SIZE {
298 return Err(format!(
299 "Output size {} bytes exceeds maximum {} bytes (10MB)",
300 total_size, MAX_FILE_SIZE
301 ));
302 }
303
304 let mut header = if options.use_timestamp {
306 FafbHeader::with_timestamp()
307 } else {
308 FafbHeader::new()
309 };
310 header.set_source_checksum(source_bytes);
311 header.section_count = section_count as u16;
312 header.section_table_offset = section_table_offset;
313 header.total_size = total_size;
314 header.string_table_index = st_section_index;
315
316 let mut output: Vec<u8> = Vec::with_capacity(total_size as usize);
318 header.write(&mut output).map_err(|e| e.to_string())?;
319 output.write_all(§ion_data).map_err(|e| e.to_string())?;
320 section_table
321 .write(&mut output)
322 .map_err(|e| e.to_string())?;
323
324 if output.len() != total_size as usize {
325 return Err(format!(
326 "Internal error: size mismatch (expected {} bytes, got {} bytes)",
327 total_size,
328 output.len()
329 ));
330 }
331
332 Ok(output)
333}
334
335pub fn decompile(fafb_bytes: &[u8]) -> FafbResult<DecompiledFafb> {
356 let header = FafbHeader::from_bytes(fafb_bytes)?;
357 header.validate(fafb_bytes)?;
358
359 let table_start = header.section_table_offset as usize;
361 let table_data = &fafb_bytes[table_start..];
362 let section_table = SectionTable::from_bytes(table_data, header.section_count as usize)?;
363 section_table.validate_bounds(header.total_size)?;
364
365 let st_index = header.string_table_index as usize;
367 if st_index >= section_table.len() {
368 return Err(FafbError::MissingStringTable);
369 }
370 let st_entry = section_table.get(st_index).unwrap();
371 let st_start = st_entry.offset as usize;
372 let st_end = st_start + st_entry.length as usize;
373 if st_end > fafb_bytes.len() {
374 return Err(FafbError::MissingStringTable);
375 }
376 let string_table = StringTable::from_bytes(&fafb_bytes[st_start..st_end])?;
377
378 Ok(DecompiledFafb {
379 header,
380 section_table,
381 data: fafb_bytes.to_vec(),
382 string_table,
383 })
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn opts() -> CompileOptions {
391 CompileOptions {
392 use_timestamp: false,
393 }
394 }
395
396 fn minimal_yaml() -> &'static str {
397 "faf_version: 2.5.0\nproject:\n name: test-project\n"
398 }
399
400 fn full_yaml() -> &'static str {
401 r#"faf_version: 2.5.0
402project:
403 name: full-project
404 goal: Test the compiler
405tech_stack:
406 languages:
407 - Rust
408 - TypeScript
409commands:
410 build: cargo build
411 test: cargo test
412architecture:
413 style: microservices
414context:
415 notes: some context
416docs:
417 readme: README.md
418custom_field:
419 key: value
420another_custom:
421 deep:
422 nested: data
423"#
424 }
425
426 #[test]
429 fn test_compile_produces_valid_v2_header() {
430 let bytes = compile(minimal_yaml(), &opts()).unwrap();
431 assert_eq!(&bytes[0..4], b"FAFB");
432 assert_eq!(bytes[4], 2); assert!(bytes.len() >= HEADER_SIZE);
434 }
435
436 #[test]
437 fn test_compile_empty_fails() {
438 assert!(compile("", &opts()).is_err());
439 }
440
441 #[test]
442 fn test_compile_options_default() {
443 let o = CompileOptions::default();
444 assert!(o.use_timestamp);
445 }
446
447 #[test]
448 fn test_roundtrip_minimal() {
449 let bytes = compile(minimal_yaml(), &opts()).unwrap();
450 let result = decompile(&bytes).unwrap();
451
452 assert_eq!(result.header.version_major, 2);
453 assert!(result.header.flags.has_string_table());
454
455 assert!(result.section_table.len() >= 3);
457
458 let project = result.get_section_string_by_name("project").unwrap();
459 assert!(project.contains("test-project"));
460 }
461
462 #[test]
463 fn test_v1_binaries_rejected() {
464 let mut bytes = compile(minimal_yaml(), &opts()).unwrap();
465 bytes[4] = 1; let err = decompile(&bytes).unwrap_err();
467 assert!(matches!(
468 err,
469 FafbError::IncompatibleVersion { actual: 1, .. }
470 ));
471 }
472
473 #[test]
474 fn test_roundtrip_full() {
475 let bytes = compile(full_yaml(), &opts()).unwrap();
476 let result = decompile(&bytes).unwrap();
477
478 let st = result.string_table();
479 assert!(st.index_of("faf_version").is_some());
480 assert!(st.index_of("project").is_some());
481 assert!(st.index_of("tech_stack").is_some());
482 assert!(st.index_of("commands").is_some());
483
484 assert!(st.index_of("docs").is_none());
487 assert!(st.index_of("custom_field").is_none());
488 assert!(st.index_of("another_custom").is_none());
489 }
490
491 #[test]
492 fn test_decompile_invalid_magic() {
493 let bytes = vec![0u8; 32];
494 assert!(decompile(&bytes).is_err());
495 }
496
497 #[test]
498 fn test_decompile_too_small() {
499 let bytes = vec![0u8; 16];
500 assert!(decompile(&bytes).is_err());
501 }
502
503 #[test]
504 fn test_source_checksum() {
505 let yaml = full_yaml();
506 let bytes = compile(yaml, &opts()).unwrap();
507 let result = decompile(&bytes).unwrap();
508
509 let expected = FafbHeader::compute_checksum(yaml.as_bytes());
510 assert_eq!(result.header.source_checksum, expected);
511 }
512
513 #[test]
514 fn test_deterministic_without_timestamp() {
515 let yaml = minimal_yaml();
516 let bytes1 = compile(yaml, &opts()).unwrap();
517 let bytes2 = compile(yaml, &opts()).unwrap();
518 assert_eq!(bytes1, bytes2);
519 }
520
521 #[test]
522 fn test_canonical_order_key_order_independent() {
523 let a = "faf_version: 2.5.0\nproject:\n name: x\ncommands:\n build: make\n";
527 let b = "commands:\n build: make\nfaf_version: 2.5.0\nproject:\n name: x\n";
528
529 let mut bytes_a = compile(a, &opts()).unwrap();
530 let mut bytes_b = compile(b, &opts()).unwrap();
531 for buf in [&mut bytes_a, &mut bytes_b] {
532 for byte in &mut buf[8..12] {
533 *byte = 0;
534 }
535 }
536 assert_eq!(bytes_a, bytes_b);
537 }
538
539 #[test]
542 fn test_non_canonical_keys_folded_into_context() {
543 let yaml =
544 "faf_version: 2.5.0\nproject:\n name: test\nmy_exotic_field:\n data: preserved\n";
545 let bytes = compile(yaml, &opts()).unwrap();
546 let result = decompile(&bytes).unwrap();
547
548 assert!(result.string_table().index_of("my_exotic_field").is_none());
550 let context = result.get_section_string_by_name("context").unwrap();
552 assert!(context.contains("my_exotic_field"));
553 assert!(context.contains("preserved"));
554 }
555
556 #[test]
557 fn test_folding_preserves_authored_context() {
558 let bytes = compile(full_yaml(), &opts()).unwrap();
559 let result = decompile(&bytes).unwrap();
560
561 let context = result.get_section_string_by_name("context").unwrap();
562 assert!(context.contains("notes")); assert!(context.contains("custom_field")); assert!(context.contains("another_custom")); }
566
567 #[test]
568 fn test_folding_collision_is_an_error() {
569 let yaml = "project:\n name: x\ncontext:\n dupe: authored\ndupe: folded\n";
570 assert!(compile(yaml, &opts()).is_err());
572 }
573
574 #[test]
575 fn test_folding_into_scalar_context_is_an_error() {
576 let yaml = "project:\n name: x\ncontext: just a string\nweird_key: value\n";
577 assert!(compile(yaml, &opts()).is_err());
578 }
579
580 #[test]
583 fn test_section_names() {
584 let bytes = compile(full_yaml(), &opts()).unwrap();
585 let result = decompile(&bytes).unwrap();
586
587 for entry in result.section_table.entries() {
588 let name = result.section_name(entry);
589 assert!(!name.is_empty());
590 assert_ne!(name, "UNKNOWN");
591 }
592 }
593
594 #[test]
595 fn test_get_section_by_name() {
596 let bytes = compile(full_yaml(), &opts()).unwrap();
597 let result = decompile(&bytes).unwrap();
598
599 let project = result.get_section_string_by_name("project");
600 assert!(project.is_some());
601 assert!(project.unwrap().contains("full-project"));
602
603 assert!(result.get_section_string_by_name("docs").is_none());
605 let context = result.get_section_string_by_name("context").unwrap();
606 assert!(context.contains("docs"));
607 assert!(context.contains("README.md"));
608 }
609
610 #[test]
613 fn test_classification_dna() {
614 let bytes = compile(full_yaml(), &opts()).unwrap();
615 let result = decompile(&bytes).unwrap();
616
617 let dna = result.dna_sections();
618 let dna_names: Vec<String> = dna.iter().map(|e| result.section_name(e)).collect();
619
620 assert!(dna_names.contains(&"faf_version".to_string()));
621 assert!(dna_names.contains(&"project".to_string()));
622 assert!(dna_names.contains(&"tech_stack".to_string()));
623 assert!(dna_names.contains(&"commands".to_string()));
624 assert!(dna_names.contains(&"architecture".to_string()));
625
626 let ctx_names: Vec<String> = result
628 .context_sections()
629 .iter()
630 .map(|e| result.section_name(e))
631 .collect();
632 assert!(ctx_names.contains(&"context".to_string()));
633 }
634
635 #[test]
636 fn test_no_pointer_section_in_fafdata_truth() {
637 let bytes = compile(full_yaml(), &opts()).unwrap();
640 let result = decompile(&bytes).unwrap();
641 assert!(result.pointer_section().is_none());
642 }
643
644 #[test]
647 fn test_string_table_flag_set() {
648 let bytes = compile(minimal_yaml(), &opts()).unwrap();
649 let result = decompile(&bytes).unwrap();
650 assert!(result.header.flags.has_string_table());
651 }
652
653 #[test]
654 fn test_string_table_index_valid() {
655 let bytes = compile(minimal_yaml(), &opts()).unwrap();
656 let result = decompile(&bytes).unwrap();
657
658 let st_idx = result.header.string_table_index as usize;
659 assert!(st_idx < result.section_table.len());
660 }
661
662 #[test]
665 fn test_priority_from_canon_table() {
666 let bytes = compile(full_yaml(), &opts()).unwrap();
667 let result = decompile(&bytes).unwrap();
668
669 for entry in result.section_table.entries() {
670 let name = result.section_name(entry);
671 match name.as_str() {
672 "faf_version" | "project" => assert!(entry.priority.is_critical()),
673 "commands" => assert_eq!(entry.priority.value(), 180),
674 "architecture" => assert_eq!(entry.priority.value(), 128),
675 "context" | "scores" => assert_eq!(entry.priority.value(), 64),
676 _ => {}
677 }
678 }
679 }
680
681 #[test]
684 fn test_all_canonical_chunks_compile() {
685 let yaml = r#"faf_version: 2.5.0
687project:
688 name: all-types
689app_type: cli
690about:
691 represents: Wolfe-Jam/source
692stack:
693 build: cargo
694human_context:
695 who: devs
696monorepo:
697 packages_count: 3
698tech_stack:
699 - Rust
700key_files:
701 - main.rs
702commands:
703 build: make
704architecture:
705 style: monolith
706scores:
707 total: 100
708context:
709 note: x
710"#;
711 let bytes = compile(yaml, &opts()).unwrap();
712 let result = decompile(&bytes).unwrap();
713
714 let st = result.string_table();
715 for key in &[
716 "faf_version",
717 "project",
718 "app_type",
719 "about",
720 "stack",
721 "human_context",
722 "monorepo",
723 "tech_stack",
724 "key_files",
725 "commands",
726 "architecture",
727 "scores",
728 "context",
729 ] {
730 assert!(
731 st.index_of(key).is_some(),
732 "Expected canonical chunk '{}' in string table",
733 key
734 );
735 }
736 }
737}